Merge pull request #5283 from pewdiepie-archdaemon/sync-main-into-dev-20260707

chore: sync tested main into dev
This commit is contained in:
PewDiePie
2026-07-07 11:32:53 +09:00
committed by GitHub
78 changed files with 23176 additions and 1049 deletions
+101 -37
View File
@@ -3,6 +3,7 @@ import mimetypes
import os
import sys
import asyncio
import time
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
@@ -204,12 +205,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
path = request.url.path or ""
if not should_track_interactive_request(path, request.method):
return await call_next(request)
async def _stop_background():
try:
await task_scheduler.stop_background_tasks_for_foreground(reason=f"foreground request {request.method} {path}")
except Exception:
logging.getLogger("app.foreground_gate").debug("foreground task stop failed", exc_info=True)
asyncio.create_task(_stop_background())
async with track_interactive_request(path, request.method):
return await call_next(request)
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.perf_counter()
status = 500
try:
response = await call_next(request)
status = getattr(response, "status_code", 0) or 0
return response
finally:
elapsed = time.perf_counter() - start
try:
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
except Exception:
threshold = 0.75
if elapsed >= threshold:
logging.getLogger("app.slow_request").warning(
"slow_request method=%s path=%s status=%s elapsed=%.3fs",
request.method,
request.url.path,
status,
elapsed,
)
app.add_middleware(_RequestTimeoutMiddleware)
app.add_middleware(_InteractiveActivityMiddleware)
app.add_middleware(_SlowRequestLogMiddleware)
# ========= AUTH =========
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
@@ -600,6 +632,12 @@ app.include_router(auth_router)
async def activity_heartbeat():
from src.interactive_gate import mark_browser_activity
await mark_browser_activity()
async def _stop_background():
try:
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
except Exception:
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
asyncio.create_task(_stop_background())
return {"ok": True}
@@ -889,6 +927,34 @@ async def get_version():
async def health_check() -> Dict[str, str]:
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
@app.post("/api/client-perf")
async def client_perf(request: Request):
"""Low-volume frontend timing reports for stalls that happen before SSE logs."""
try:
data = await request.json()
except Exception:
data = {}
try:
kind = str(data.get("type") or "client").replace("\n", " ")[:80]
total_ms = float(data.get("total_ms") or 0)
stages = data.get("stages") if isinstance(data.get("stages"), list) else []
stage_txt = " ".join(
f"{str(s.get('name') or '')[:40]}={float(s.get('delta_ms') or 0):.0f}ms"
for s in stages[:20]
if isinstance(s, dict)
)
extra = str(data.get("extra") or "").replace("\n", " ")[:200]
logging.getLogger("app.client_perf").warning(
"client_perf type=%s total=%.0fms %s%s",
kind,
total_ms,
stage_txt,
f" extra={extra}" if extra else "",
)
except Exception:
logging.getLogger("app.client_perf").debug("client_perf log failed", exc_info=True)
return {"ok": True}
@app.get("/api/ready")
async def readiness_check() -> JSONResponse:
"""Readiness / integrity self-check — DB, data dir, local-first storage.
@@ -985,45 +1051,43 @@ async def _startup_event():
_startup_tasks.append(asyncio.create_task(_startup_mcp_connections()))
# Pre-warm the RAG tool index off the request path. Loading the local
# embedding model + opening ChromaDB + indexing the built-in tools is a
# one-time ~1-3s cost that otherwise lands on the user's FIRST message
# (showing up as a big `tool_selection` time). Doing it here makes the
# first turn as fast as subsequent ones (warm embed ≈ a few ms).
async def _warmup_tool_index():
try:
from src.tool_index import get_tool_index
idx = await asyncio.to_thread(get_tool_index)
if idx:
await asyncio.to_thread(idx.get_tools_for_query, "warmup", 8)
logger.info("[startup] Tool index pre-warmed")
except Exception as e:
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
# Startup warmups are opt-in. They make later requests a little warmer, but
# they also compete with the first seconds of real UI use on slow or busy
# machines. Default to clear/idle startup and let requests warm what they use.
_startup_warmups_enabled = str(os.getenv("ODYSSEUS_STARTUP_WARMUPS", "")).lower() in {"1", "true", "yes", "on"}
if _startup_warmups_enabled:
async def _warmup_tool_index():
try:
from src.tool_index import get_tool_index
idx = await asyncio.to_thread(get_tool_index)
if idx:
await asyncio.to_thread(idx.get_tools_for_query, "warmup", 8)
logger.info("[startup] Tool index pre-warmed")
except Exception as e:
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
# Warmup: ping all known LLM endpoints to prime connections
async def _warmup_endpoints():
try:
import httpx
# model_discovery has no get_endpoints(); that call raised
# AttributeError every run and silently disabled warmup/keepalive.
# Resolve the /models probe URLs via the real discovery API, off the
# event loop since discovery does a blocking port scan.
urls = (
await asyncio.to_thread(model_discovery.warmup_ping_urls)
if model_discovery else []
)
for url in urls:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.get(url)
logger.info(f"Warmup ping OK: {url}")
except Exception as e:
logger.debug(f"Warmup ping failed for endpoint: {e}")
except Exception as e:
logger.debug(f"Warmup ping skipped: {e}")
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
async def _warmup_endpoints():
try:
import httpx
urls = (
await asyncio.to_thread(model_discovery.warmup_ping_urls)
if model_discovery else []
)
for url in urls:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.get(url)
logger.info(f"Warmup ping OK: {url}")
except Exception as e:
logger.debug(f"Warmup ping failed for endpoint: {e}")
except Exception as e:
logger.debug(f"Warmup ping skipped: {e}")
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
else:
logger.info("Startup warmups disabled (set ODYSSEUS_STARTUP_WARMUPS=1 to enable)")
# Keep-alive is opt-in. The ping path performs model discovery, and when
# stale LAN endpoints are configured it can add periodic backend pressure
+16
View File
@@ -14,6 +14,7 @@ from core.database import Session as DBSession, ModelEndpoint
from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context
from src.model_context import estimate_tokens
from src.auth_helpers import effective_user
from src.prompt_security import untrusted_context_message
from routes.prefs_routes import _load_for_user as load_prefs_for_user
@@ -99,6 +100,11 @@ class ChatContext:
uprefs: dict
preset: PresetInfo
preprocessed: PreprocessedMessage
context_trimmed: bool = False
context_messages_before_trim: int = 0
context_messages_after_trim: int = 0
context_tokens_before_trim: int = 0
context_tokens_after_trim: int = 0
# Documents auto-created server-side during preprocess (e.g. when an
# attached fillable PDF gets rendered into a markdown editor doc).
# The chat route emits a doc_update SSE event for each before streaming
@@ -777,7 +783,12 @@ async def build_chat_context(
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
_before_trim_messages = len(messages)
_before_trim_tokens = estimate_tokens(messages)
messages = trim_for_context(messages, context_length)
_after_trim_messages = len(messages)
_after_trim_tokens = estimate_tokens(messages)
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
return ChatContext(
preface=preface,
@@ -791,6 +802,11 @@ async def build_chat_context(
uprefs=uprefs,
preset=preset,
preprocessed=preprocessed,
context_trimmed=_context_trimmed,
context_messages_before_trim=_before_trim_messages,
context_messages_after_trim=_after_trim_messages,
context_tokens_before_trim=_before_trim_tokens,
context_tokens_after_trim=_after_trim_tokens,
auto_opened_docs=auto_opened_docs,
uploaded_files=uploaded_files,
)
+154 -10
View File
@@ -3,6 +3,7 @@
import asyncio
import json
import os
import re
import time
import logging
from datetime import datetime
@@ -40,7 +41,7 @@ from routes.chat_helpers import (
clean_thinking_for_save,
_enforce_chat_privileges,
)
from src.action_intents import classify_tool_intent as _classify_tool_intent
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
from src.tool_policy import build_effective_tool_policy
logger = logging.getLogger(__name__)
@@ -63,6 +64,78 @@ def _stream_set(session_id: str, **fields) -> None:
rec.update(fields)
def _message_plain_text(content: Any) -> str:
if isinstance(content, list):
parts: List[str] = []
for block in content:
if isinstance(block, dict):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
elif isinstance(block, str):
parts.append(block)
return " ".join(parts)
return str(content or "")
def _last_user_plain_text(messages: List[Dict[str, Any]]) -> str:
for msg in reversed(messages or []):
if msg.get("role") == "user":
return _message_plain_text(msg.get("content"))
return ""
def _ensure_current_request_is_latest_user(messages: List[Dict[str, Any]], current_message: str) -> List[Dict[str, Any]]:
"""Defensively keep detached streams grounded on the request that created them."""
current = str(current_message or "").strip()
if not current:
return messages
latest = _last_user_plain_text(messages).strip()
if latest == current or current in latest or latest in current:
return messages
logger.warning(
"[chat_stream] latest user context mismatch; appending current request for model call. latest=%r current=%r",
latest[:120],
current[:120],
)
repaired = list(messages or [])
repaired.append({"role": "user", "content": current})
return repaired
_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*$",
re.I,
)
_RECENT_WEB_CONTEXT_RE = re.compile(
r"\b(?:weather|forecast|rain|raining|hourly|news|headlines|rate|exchange|currency|"
r"price|current|latest|search|look\s+up|online)\b",
re.I,
)
def _recent_session_text(sess, limit: int = 8, max_chars: int = 2000) -> str:
history = getattr(sess, "history", None) or getattr(sess, "_history", None) or []
chunks: List[str] = []
for msg in history[-limit:]:
content = getattr(msg, "content", None)
if content is None and isinstance(msg, dict):
content = msg.get("content")
text = _message_plain_text(content).strip()
if text:
chunks.append(text)
return " ".join(chunks)[-max_chars:]
def _is_contextual_web_followup(message: str, sess) -> bool:
"""Treat short retry/check replies as web lookups when recent context was web."""
if not message or not _WEB_FOLLOWUP_RE.search(message):
return False
return bool(_RECENT_WEB_CONTEXT_RE.search(_recent_session_text(sess)))
def _resolve_request_workspace(request, raw_value) -> tuple:
"""Resolve the posted workspace for this request: (workspace, rejected).
@@ -510,6 +583,10 @@ def setup_chat_routes(
# below). Skill extraction should only learn from real agent sessions,
# not chats we quietly promoted for a notes/calendar intent.
user_requested_agent = (chat_mode == "agent")
_search_enabled = (
str(allow_web_search).lower() == "true"
or str(use_web).lower() == "true"
)
# 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.
@@ -527,6 +604,10 @@ def setup_chat_routes(
_tool_intent.category,
_tool_intent.reason,
)
elif chat_mode == "chat" and _search_enabled:
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: search enabled")
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}")
@@ -619,6 +700,20 @@ def setup_chat_routes(
400,
"No model selected for this chat. Open the model picker and choose one before sending.",
)
if (
chat_mode == "chat"
and isinstance(message, str)
and (not _tool_intent or not _tool_intent.needs_tools)
and _is_contextual_web_followup(message, sess)
):
_tool_intent = ToolIntent(True, "web", "contextual web lookup follow-up")
chat_mode = "agent"
auto_escalated = True
logger.info(
"chat→agent auto-escalation: category=%s reason=%s",
_tool_intent.category,
_tool_intent.reason,
)
except SessionNotFoundError as e:
raise HTTPException(404, str(e))
except (ValueError, ValidationError):
@@ -787,6 +882,25 @@ def setup_chat_routes(
):
disabled_tools.add("web_search")
disabled_tools.add("web_fetch")
if _explicit_web_intent:
# A direct lookup/search request should not drift into personal
# tools or shell fallbacks. We still keep web_search/web_fetch
# available even when the frontend toggle is stale/falsy because
# the user's words are the stronger signal.
disabled_tools.update({
"bash", "python",
"search_chats", "manage_skills", "manage_memory",
"read_file", "write_file", "edit_file",
"create_document", "edit_document", "update_document",
"send_email", "reply_to_email",
"manage_notes", "manage_calendar", "manage_tasks",
"api_call", "builtin_browser",
})
disabled_tools.discard("web_search")
disabled_tools.discard("web_fetch")
elif _search_enabled:
disabled_tools.discard("web_search")
disabled_tools.discard("web_fetch")
# Nobody/incognito mode: deny tools that would expose the user's
# persistent memory, past chats, or other identity-linked data.
@@ -837,7 +951,10 @@ def setup_chat_routes(
from src.settings import get_setting
_global_disabled = get_setting("disabled_tools", [])
if _global_disabled and isinstance(_global_disabled, list):
explicit_web_allowed = allow_web_search is not None and str(allow_web_search).lower() == "true"
explicit_web_allowed = (
_explicit_web_intent
or (allow_web_search is not None and str(allow_web_search).lower() == "true")
)
if explicit_web_allowed:
disabled_tools.update(t for t in _global_disabled if t not in {"web_search", "web_fetch"})
else:
@@ -1055,13 +1172,16 @@ def setup_chat_routes(
_active_streams.pop(session, None)
return
messages = ctx.messages
messages = _ensure_current_request_is_latest_user(ctx.messages, message)
# Auto-compact notification
if ctx.was_compacted:
yield f"data: {json.dumps({'type': 'compacted', 'context_length': ctx.context_length})}\n\n"
if ctx.context_trimmed and not ctx.was_compacted:
yield f"data: {json.dumps({'type': 'context_trimmed', 'data': {'context_length': ctx.context_length, 'messages_before': ctx.context_messages_before_trim, 'messages_after': ctx.context_messages_after_trim, 'tokens_before': ctx.context_tokens_before_trim, 'tokens_after': ctx.context_tokens_after_trim}})}\n\n"
full_response = ""
thinking_response = ""
last_metrics = None
# Configured fallback chain for the default chat model. Tried in
@@ -1151,7 +1271,9 @@ def setup_chat_routes(
# Forward them so the client can show a thinking
# indicator, but don't fold them into the saved
# reply (mirrors the rewrite path below).
if not data.get("thinking"):
if data.get("thinking"):
thinking_response += data["delta"]
else:
full_response += data["delta"]
_stream_set(session, partial=full_response)
yield chunk
@@ -1171,6 +1293,12 @@ def setup_chat_routes(
_reported_model = last_metrics.get("model")
last_metrics["requested_model"] = _requested_model
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
if ctx.context_trimmed:
last_metrics["context_trimmed"] = True
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
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)
last_metrics["context_percent"] = pct
@@ -1213,8 +1341,11 @@ def setup_chat_routes(
}
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
if full_response:
_metrics_to_save = dict(last_metrics or {})
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
_metrics_to_save["thinking"] = thinking_response.strip()
_saved_id = save_assistant_response(
sess, session_manager, session, full_response, last_metrics,
sess, session_manager, session, full_response, _metrics_to_save,
character_name=ctx.preset.character_name,
web_sources=web_sources,
rag_sources=ctx.rag_sources,
@@ -1227,7 +1358,7 @@ def setup_chat_routes(
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
run_post_response_tasks(
sess, session_manager, session, message, full_response,
last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
owner=_user,
@@ -1279,7 +1410,9 @@ def setup_chat_routes(
_max_rounds = max(1, min(_max_rounds, 200))
_forced_tools = None
if allow_web_search is not None and str(allow_web_search).lower() == "true":
if _explicit_web_intent:
_forced_tools = {"web_search", "web_fetch"}
elif _search_enabled:
_forced_tools = {"web_search", "web_fetch"}
async for chunk in stream_agent_loop(
@@ -1313,7 +1446,9 @@ def setup_chat_routes(
# Reasoning tokens arrive flagged thinking:true.
# Forward them for the live indicator, but keep
# them out of the saved reply (same as chat mode).
if not data.get("thinking"):
if data.get("thinking"):
thinking_response += data["delta"]
else:
full_response += data["delta"]
_stream_set(session, partial=full_response)
yield chunk
@@ -1351,6 +1486,12 @@ def setup_chat_routes(
_reported_model = last_metrics.get("model")
last_metrics["requested_model"] = last_metrics.get("requested_model") or _requested_model
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
if ctx.context_trimmed:
last_metrics["context_trimmed"] = True
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
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
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
except json.JSONDecodeError:
yield chunk
@@ -1360,8 +1501,11 @@ def setup_chat_routes(
_has_tool_events = bool((last_metrics or {}).get("tool_events"))
if full_response or _has_tool_events:
_response_to_save = full_response or "Done."
_metrics_to_save = dict(last_metrics or {})
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
_metrics_to_save["thinking"] = thinking_response.strip()
_saved_id = save_assistant_response(
sess, session_manager, session, _response_to_save, last_metrics,
sess, session_manager, session, _response_to_save, _metrics_to_save,
character_name=ctx.preset.character_name,
web_sources=web_sources,
rag_sources=ctx.rag_sources,
@@ -1372,7 +1516,7 @@ def setup_chat_routes(
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
run_post_response_tasks(
sess, session_manager, session, message, _response_to_save,
last_metrics, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
agent_rounds=_agent_rounds,
+32 -16
View File
@@ -394,21 +394,30 @@ def _resolve_resource_url(uid: str) -> str:
return _lookup() or _vcard_url(uid)
def _create_contact(name: str, email: str, address: str = "") -> bool:
def _create_contact(name: str, email: str = "", address: str = "", phones: Optional[List[str]] = None) -> bool:
"""Add a new contact via CardDAV or local contacts."""
email = (email or "").strip()
phone_list = [str(p or "").strip() for p in (phones or []) if str(p or "").strip()]
cfg = _get_carddav_config()
if not _carddav_configured(cfg):
contacts = _load_local_contacts()
email_l = (email or "").strip().lower()
email_l = email.lower()
for c in contacts:
if email_l and email_l in [e.lower() for e in c.get("emails", [])]:
return True
contacts.append(_normalize_contact({"name": name, "emails": [email], "address": address}))
if phone_list and any(p in (c.get("phones") or []) for p in phone_list):
return True
contacts.append(_normalize_contact({
"name": name,
"emails": [email] if email else [],
"phones": phone_list,
"address": address,
}))
_save_local_contacts(contacts)
return True
contact_uid = str(uuid.uuid4())
vcard = _build_vcard(name, email, contact_uid, address=address)
vcard = _build_vcard(name, email, contact_uid, address=address, phones=phone_list)
try:
url = _carddav_base_url(cfg) + "/" + contact_uid + ".vcf"
auth = None
@@ -759,26 +768,33 @@ def setup_contacts_routes():
name = (data.get("name") or "").strip()
email = (data.get("email") or "").strip()
phone = (data.get("phone") or "").strip()
phones = [str(p or "").strip() for p in (data.get("phones") or []) if str(p or "").strip()]
if phone and phone not in phones:
phones.insert(0, phone)
address = (data.get("address") or "").strip()
if not email:
return {"success": False, "error": "Email required"}
# Check if already exists by email
if email:
contacts = _fetch_contacts()
for c in contacts:
if email.lower() in [e.lower() for e in c["emails"]]:
return {"success": True, "message": "Already exists", "contact": c}
if not name:
if not name and email:
name = email.split("@")[0]
if not name and not email and not phones and not address:
return {"success": False, "error": "Name, email, phone, or address required"}
if not name:
name = email.split("@")[0] if email else (phones[0] if phones else "Contact")
contacts = _fetch_contacts()
for c in contacts:
if email and email.lower() in [e.lower() for e in c.get("emails", [])]:
return {"success": True, "message": "Already exists", "contact": c}
if phones and any(p in (c.get("phones") or []) for p in phones):
return {"success": True, "message": "Already exists", "contact": c}
create_params = inspect.signature(_create_contact).parameters
if len(create_params) >= 3:
if "phones" in create_params:
ok = _create_contact(name, email, address, phones=phones)
elif len(create_params) >= 3:
ok = _create_contact(name, email, address)
else:
ok = _create_contact(name, email)
# If a phone was provided, do an immediate update to thread it
# through (the simple _create_contact signature only takes name +
# email + address; phones happen via update).
if ok and phone:
if ok and phones and "phones" not in create_params:
try:
fresh = _fetch_contacts(force=True)
created = next((c for c in fresh if name == c.get("name") and (not email or email in c.get("emails", []))), None)
@@ -786,7 +802,7 @@ def setup_contacts_routes():
_update_contact(
created["uid"], name,
created.get("emails", []),
[phone],
phones,
address,
)
except Exception:
+2 -9
View File
@@ -2,15 +2,8 @@
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.contacts_routes``, ``from routes.contacts_routes import X``,
``importlib.import_module("routes.contacts_routes")``, and the string-targeted
``monkeypatch.setattr("routes.contacts_routes.SETTINGS_FILE", ...)`` pattern
used by test_carddav_password_encryption.py / test_contacts_carddav_security.py
— plus the ``import ... as contacts_routes`` + ``setattr(...)`` pattern in
test_contacts_add_null_name.py — all operate on the *same* object the
application actually uses. This also keeps ``_contact_cache`` (mutable module
state) identical across import paths. Keeps existing import paths working
after slice 2e (#4082/#4071). No source-introspection tests read this file
by path.
``importlib.import_module("routes.contacts_routes")``, and string-targeted
monkeypatches all operate on the same object the application actually uses.
"""
import sys as _sys
+52 -11
View File
@@ -439,15 +439,30 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" snap = os.path.join(cache, d, 'snapshots')",
" # Windows HF cache stores files directly in snapshots/; blobs/ may be empty.",
" # Fallback: scan snapshots for real files when blobs yielded nothing.",
" if sz == 0 and os.path.isdir(snap):",
" def snapshot_size():",
" total, count, incomplete = 0, 0, False",
" seen_real = set()",
" for sd in os.listdir(snap):",
" sf = os.path.join(snap, sd)",
" if not os.path.isdir(sf): continue",
" for f in os.scandir(sf):",
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" for root, dirs, fns in safe_walk(sf):",
" for fn in fns:",
" fp = os.path.join(root, fn)",
" if fn.endswith('.incomplete'): incomplete = True",
" try:",
" real = os.path.realpath(fp)",
" if real in seen_real: continue",
" seen_real.add(real)",
" total += os.path.getsize(real)",
" count += 1",
" except Exception:",
" pass",
" return total, count, incomplete",
" # Some HF caches (macOS/MLX/Xet-style) keep blobs elsewhere or expose",
" # snapshot symlinks only. Size snapshots too when blob accounting is empty.",
" 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 = []",
" if os.path.isdir(snap):",
" for sd in os.listdir(snap):",
@@ -471,7 +486,18 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" add('/app/.cache/huggingface/hub')",
f" add({add_hf_cache!r})" if add_hf_cache else "",
" return candidates",
"def normalize_model_dir(p):",
" p = os.path.expanduser((p or '').strip())",
" if not p: return p",
" if os.path.isdir(p) or os.path.isabs(p): return p",
" # Users often paste Linux absolute paths without the leading slash.",
" # Treat home/<user>/... as /home/<user>/... so remote scans work.",
" if p.startswith(('home/', 'mnt/', 'media/', 'data/', 'opt/', 'srv/', 'var/')):",
" prefixed = '/' + p",
" if os.path.isdir(prefixed): return prefixed",
" return p",
"def scan_dir(p):",
" p = normalize_model_dir(p)",
" if not os.path.isdir(p) or not safe_path(p): return",
" for d in sorted(os.listdir(p)):",
" if d.startswith('.'): continue",
@@ -541,7 +567,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
"scan_ollama()",
]
for model_dir in model_dirs or []:
lines.append(f"scan_dir(os.path.expanduser({model_dir!r}))")
lines.append(f"scan_dir({model_dir!r})")
lines.append("print(json.dumps(models))")
return "\n".join(lines) + "\n"
@@ -1273,13 +1299,15 @@ def _diagnose_serve_output(text: str) -> dict | None:
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
),
(
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
r"Could not load any common_ops library|"
r"Please ensure sgl_kernel is properly installed",
"SGLang native dependencies are missing on this server.",
"SGLang native kernel/runtime is missing or mismatched on this server.",
[
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
],
),
(
@@ -1287,6 +1315,19 @@ def _diagnose_serve_output(text: str) -> dict | None:
"SGLang is not installed or not in PATH on this server.",
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
),
(
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
"MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
),
(
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.",
[
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
],
),
# System build deps come BEFORE the generic llama.cpp catch-all so
# cmake / build-essential / git missing → a specific OS-package
# remediation instead of "install llama-cpp-python[server]" (which
+648 -87
View File
File diff suppressed because it is too large Load Diff
+71 -10
View File
@@ -54,6 +54,18 @@ def _library_language_for_document(doc: Document) -> str:
return doc.language or "text"
def _email_source_key(content: str) -> tuple[str, str]:
"""Return the source email identity embedded in an email draft document."""
import re
text = content or ""
uid_m = re.search(r"(?im)^X-Source-UID:\s*(.+?)\s*$", text)
folder_m = re.search(r"(?im)^X-Source-Folder:\s*(.+?)\s*$", text)
uid = (uid_m.group(1).strip() if uid_m else "")
folder = (folder_m.group(1).strip() if folder_m else "INBOX")
return uid, folder
from routes.document_helpers import (
DocumentCreate, DocumentUpdate, DocumentPatch,
_doc_to_dict, _version_to_dict,
@@ -99,24 +111,62 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
# the existing lenient path.
session = _get_session_or_404(db, req.session_id, user)
doc_id = str(uuid.uuid4())
ver_id = str(uuid.uuid4())
# If no language was supplied (e.g. cloning a doc whose language
# was never set), detect it from the content rather than storing
# NULL — which made the editor fall back to plain text. Defaults
# to markdown for prose.
language = req.language
if not language:
from src.agent_tools.document_tools import _looks_like_email_document, _sniff_doc_language
from src.agent_tools.document_tools import _looks_like_email_document, _sniff_doc_language, _coerce_email_document_content
language = _sniff_doc_language(req.content)
else:
from src.agent_tools.document_tools import _looks_like_email_document
from src.agent_tools.document_tools import _looks_like_email_document, _coerce_email_document_content
if _looks_like_email_document(req.content, req.title):
language = "email"
_assert_pdf_marker_upload_owned(request, req.content, user, upload_handler)
# Reply drafts are keyed to the source email. If a UI/tool path tries
# to create a second draft for the same email in the same chat,
# update the existing draft instead so quoted thread history stays
# attached to the visible document.
if language == "email" and req.session_id:
source_uid, source_folder = _email_source_key(req.content)
if source_uid:
candidates = (
db.query(Document)
.filter(Document.session_id == req.session_id)
.filter(Document.is_active == True)
.filter(Document.language == "email")
.order_by(Document.updated_at.desc())
.limit(25)
.all()
)
for existing in candidates:
old_uid, old_folder = _email_source_key(existing.current_content or "")
if old_uid != source_uid or old_folder != source_folder:
continue
merged = _coerce_email_document_content(existing.current_content or "", req.content)
if existing.current_content != merged:
new_ver = (existing.version_count or 1) + 1
existing.current_content = merged
existing.title = req.title or existing.title
existing.version_count = new_ver
db.add(DocumentVersion(
id=str(uuid.uuid4()),
document_id=existing.id,
version_number=new_ver,
content=merged,
summary="Updated existing email draft",
source="user",
))
db.commit()
db.refresh(existing)
return _doc_to_dict(existing)
doc_id = str(uuid.uuid4())
ver_id = str(uuid.uuid4())
doc = Document(
id=doc_id,
session_id=req.session_id,
@@ -570,12 +620,23 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
raise HTTPException(404, "Document not found")
_verify_doc_owner(db, doc, user)
incoming_content = req.content
from src.agent_tools.document_tools import _coerce_email_document_content, _looks_like_email_document
is_email_doc = (
(doc.language or "").lower() == "email"
or _looks_like_email_document(doc.current_content or "", doc.title or "")
or _looks_like_email_document(req.content or "", doc.title or "")
)
if is_email_doc:
incoming_content = _coerce_email_document_content(doc.current_content or "", req.content)
doc.language = "email"
# Skip if content is identical unless the caller explicitly wants
# a checkpoint version from the current editor state.
if doc.current_content == req.content and not req.force_version:
if doc.current_content == incoming_content and not req.force_version:
return _doc_to_dict(doc)
_assert_pdf_marker_upload_owned(request, req.content, user, upload_handler)
_assert_pdf_marker_upload_owned(request, incoming_content, user, upload_handler)
# Check if we can coalesce with the latest version
latest_ver = db.query(DocumentVersion).filter(
@@ -591,7 +652,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
age = (now - ver_time).total_seconds()
if age < VERSION_COALESCE_SECONDS:
# Update the existing version in-place
latest_ver.content = req.content
latest_ver.content = incoming_content
latest_ver.created_at = now
if req.summary:
latest_ver.summary = req.summary
@@ -603,14 +664,14 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
id=str(uuid.uuid4()),
document_id=doc_id,
version_number=new_ver,
content=req.content,
content=incoming_content,
summary=req.summary or "Manual edit",
source="user",
)
doc.version_count = new_ver
db.add(ver)
doc.current_content = req.content
doc.current_content = incoming_content
db.commit()
db.refresh(doc)
return _doc_to_dict(doc)
+308 -28
View File
@@ -23,6 +23,8 @@ import smtplib
import json
import re
import html
import io
import zipfile
from html.parser import HTMLParser as _HTMLParser
import logging
import uuid
@@ -33,7 +35,7 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, StreamingResponse
from src.constants import DATA_DIR
from src.llm_core import llm_call_async
@@ -65,6 +67,14 @@ ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
EMAIL_READ_ATTACHMENT_VERSION = 2
def _safe_attachment_zip_name(name: str, fallback: str) -> str:
"""Return a zip entry filename without path traversal or empty names."""
base = Path(str(name or "")).name.strip() or fallback
base = re.sub(r"[\x00-\x1f\x7f]+", "_", base)
base = base.replace("/", "_").replace("\\", "_").strip(". ") or fallback
return base[:180] or fallback
def _coerce_port(value, default):
"""Coerce a user-supplied port to int.
@@ -484,23 +494,37 @@ def _email_index_rows(owner: str, account_id: str | None, folder: str, uids: lis
return out
def _email_index_search(owner: str, account_id: str | None, folder: str, query: str, limit: int) -> tuple[list[dict], int, str | None]:
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:
return [], 0, None
limit = max(1, min(int(limit or 50), 200))
account_key = _account_cache_key(account_id, owner)
like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
folder_clause = ""
params: list = [owner or "", account_key]
# Searching from INBOX should feel global for Gmail-style accounts,
# because users expect archived/labelled mail to show up too. The
# local index only contains folders that have been warmed/listed, so
# this remains a best-effort fast path; IMAP is still the fallback.
if (folder or "").upper() != "INBOX":
if not global_search or (folder or "").upper() != "INBOX":
folder_clause = "AND folder=?"
params.append(folder)
params.extend([like, like, like, like, like])
terms = _email_search_terms(q)
if not terms:
return [], 0, None
term_clause = " AND ".join([
"""(
subject LIKE ? ESCAPE '\\' OR
from_name LIKE ? ESCAPE '\\' OR
from_address LIKE ? ESCAPE '\\' OR
to_text LIKE ? ESCAPE '\\' OR
cc_text LIKE ? ESCAPE '\\'
)"""
for _ in terms
])
for term in terms:
like = "%" + term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
params.extend([like, like, like, like, like])
try:
conn = _sql3.connect(SCHEDULED_DB)
try:
@@ -509,13 +533,7 @@ def _email_index_search(owner: str, account_id: str | None, folder: str, query:
SELECT COUNT(*), MAX(updated_at)
FROM email_message_index
WHERE owner=? AND account_key=? {folder_clause}
AND (
subject LIKE ? ESCAPE '\\' OR
from_name LIKE ? ESCAPE '\\' OR
from_address LIKE ? ESCAPE '\\' OR
to_text LIKE ? ESCAPE '\\' OR
cc_text LIKE ? ESCAPE '\\'
)
AND {term_clause}
""",
params,
).fetchone()
@@ -529,13 +547,7 @@ def _email_index_search(owner: str, account_id: str | None, folder: str, query:
folder
FROM email_message_index
WHERE owner=? AND account_key=? {folder_clause}
AND (
subject LIKE ? ESCAPE '\\' OR
from_name LIKE ? ESCAPE '\\' OR
from_address LIKE ? ESCAPE '\\' OR
to_text LIKE ? ESCAPE '\\' OR
cc_text LIKE ? ESCAPE '\\'
)
AND {term_clause}
ORDER BY date_epoch DESC
LIMIT ?
""",
@@ -573,6 +585,64 @@ def _email_index_search(owner: str, account_id: str | None, folder: str, query:
return emails, total, (total_row or [None, None])[1]
def _email_search_terms(query: str) -> list[str]:
q = (query or "").strip()
if not q:
return []
# Preserve quoted phrases, then split the rest. This makes:
# honda insurance -> honda AND insurance
# "Yoko Honda" insurance -> "Yoko Honda" AND insurance
# The cap avoids creating huge IMAP expressions from pasted paragraphs.
parts = []
consumed = []
for m in re.finditer(r'"([^"]{1,120})"', q):
phrase = m.group(1).strip()
if phrase:
parts.append(phrase)
consumed.append((m.start(), m.end()))
remainder = q
for start, end in reversed(consumed):
remainder = remainder[:start] + " " + remainder[end:]
parts.extend(re.findall(r"[^\s,;]+", remainder))
out = []
seen = set()
for p in parts:
p = p.strip().strip('"').strip()
if len(p) < 2:
continue
key = p.lower()
if key in seen:
continue
seen.add(key)
out.append(p)
if len(out) >= 6:
break
return out
def _imap_or_many(keys: list[str]) -> str:
if not keys:
return "ALL"
expr = keys[0]
for key in keys[1:]:
expr = f"OR ({expr}) ({key})"
return expr
def _email_imap_search_criteria(query: str) -> str:
terms = _email_search_terms(query)
if not terms:
return "ALL"
term_exprs = []
for term in terms:
q = _imap_search_quote(term)
# Search both sides of the conversation, plus subject and body. The
# older route only searched FROM/SUBJECT/TEXT, so recipient searches
# and many sent-message searches felt broken.
term_exprs.append(f"({_imap_or_many([f'FROM {q}', f'TO {q}', f'CC {q}', f'SUBJECT {q}', f'TEXT {q}'])})")
return "(" + " ".join(term_exprs) + ")"
def _email_index_upsert(owner: str, account_id: str | None, folder: str, emails: list[dict]):
if not emails:
return
@@ -2049,6 +2119,7 @@ def setup_email_routes():
limit: int = Query(50),
account_id: str | None = Query(None),
local_only: bool = Query(False),
scope: str = Query("all"),
owner: str = Depends(require_owner),
):
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text.
@@ -2062,9 +2133,10 @@ def setup_email_routes():
# CRLF in q would terminate the IMAP command early — reject defensively.
if "\r" in q or "\n" in q:
raise HTTPException(400, "Invalid query")
global_search = (scope or "all").lower() != "folder"
indexed_response = None
try:
indexed_emails, indexed_total, indexed_at = _email_index_search(owner, account_id, folder, q, limit)
indexed_emails, indexed_total, indexed_at = _email_index_search(owner, account_id, folder, q, limit, global_search=global_search)
indexed_response = {
"emails": indexed_emails,
"total": indexed_total,
@@ -2083,7 +2155,7 @@ def setup_email_routes():
# If the user asked for INBOX, try to upgrade to All Mail —
# one folder == every email on Gmail-class servers.
effective_folder = folder
if (folder or "").upper() == "INBOX":
if global_search and (folder or "").upper() == "INBOX":
try:
status, folder_lines = conn.list()
if status == "OK" and folder_lines:
@@ -2102,12 +2174,18 @@ def setup_email_routes():
pass
conn.select(_q(effective_folder), readonly=True)
# Escape backslash and quote for the IMAP-SEARCH quoted-string.
q_escaped = q.replace('\\', '\\\\').replace('"', '\\"')
search_cmd = f'(OR OR FROM "{q_escaped}" SUBJECT "{q_escaped}" TEXT "{q_escaped}")'
search_cmd = _email_imap_search_criteria(q)
status, data = _imap_uid_search(conn, search_cmd)
if status != "OK" or not data[0]:
if indexed_response and indexed_response.get("emails"):
indexed_response["fallback"] = True
indexed_response["source"] = "index"
indexed_response["sync"] = {
**(indexed_response.get("sync") or {}),
"fallback_reason": "imap_empty" if status == "OK" else "imap_search_failed",
}
return indexed_response
return {
"emails": [],
"total": 0,
@@ -2530,6 +2608,59 @@ def setup_email_routes():
logger.error(f"Failed to download attachment {uid}/{index}: {e}")
return {"error": "Mail operation failed"}
@router.get("/attachments-download/{uid}")
async def download_all_attachments(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
"""Download all visible attachments for an email as a zip archive."""
try:
with _imap(account_id, owner=owner) as conn:
conn.select(_q(folder), readonly=True)
status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)")
if status != "OK":
raise HTTPException(status_code=404, detail="Email not found")
raw = msg_data[0][1]
msg = email_mod.message_from_bytes(raw)
attachments = [
att for att in _list_attachments_from_msg(msg)
if not _is_likely_signature_image_attachment(att)
]
if not attachments:
raise HTTPException(status_code=404, detail="No downloadable attachments")
target_dir = attachment_extract_dir(folder, uid)
zip_buf = io.BytesIO()
used_names: dict[str, int] = {}
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for att in attachments:
idx = att.get("index")
if idx is None:
continue
filepath = _extract_attachment_to_disk(msg, int(idx), target_dir)
if not filepath or not Path(filepath).exists():
continue
fallback = f"attachment-{idx}"
arcname = _safe_attachment_zip_name(att.get("filename") or Path(filepath).name, fallback)
stem = Path(arcname).stem
suffix = Path(arcname).suffix
seen = used_names.get(arcname, 0)
used_names[arcname] = seen + 1
if seen:
arcname = f"{stem}-{seen + 1}{suffix}"
zf.write(str(filepath), arcname)
zip_buf.seek(0)
if not zip_buf.getbuffer().nbytes:
raise HTTPException(status_code=404, detail="No downloadable attachments")
zip_name = _safe_attachment_zip_name(f"email-{uid}-attachments.zip", "attachments.zip")
return StreamingResponse(
zip_buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download attachments zip {uid}: {e}")
raise HTTPException(status_code=500, detail="Mail operation failed")
@router.get("/inline-image/{uid}")
async def inline_image(
uid: str,
@@ -3150,6 +3281,155 @@ def setup_email_routes():
logger.error(f"Failed to upload attachment: {e}")
return {"success": False, "error": "Mail operation failed"}
def _safe_compose_filename(name: str, fallback: str = "attachment") -> str:
safe_name = re.sub(r"[^\w\s\-.]", "_", Path(str(name or fallback)).name).strip(". ")[:180]
return safe_name or fallback
def _stage_compose_bytes(filename: str, content: bytes) -> dict:
if len(content) > EMAIL_COMPOSE_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=413, detail="Attachment too large")
safe_name = _safe_compose_filename(filename)
token = f"{uuid.uuid4().hex}_{safe_name}"
filepath = COMPOSE_UPLOADS_DIR / token
with open(filepath, "wb") as f:
f.write(content)
return {"success": True, "token": token, "filename": safe_name, "size": len(content)}
def _stage_compose_file(filename: str, src: Path) -> dict:
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="File not found")
size = src.stat().st_size
if size > EMAIL_COMPOSE_UPLOAD_MAX_BYTES:
raise HTTPException(status_code=413, detail="Attachment too large")
safe_name = _safe_compose_filename(filename)
token = f"{uuid.uuid4().hex}_{safe_name}"
dest = COMPOSE_UPLOADS_DIR / token
import shutil as _shutil
_shutil.copyfile(str(src), str(dest))
return {"success": True, "token": token, "filename": safe_name, "size": size}
def _load_odysseus_attachment_source(db, kind: str, item_id: str, owner: str):
from core.database import Document as _Doc, GalleryImage as _GI
from core.database import Session as _Sess
if kind == "document":
doc = db.query(_Doc).filter(_Doc.id == item_id, _Doc.is_active == True).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if owner:
if doc.owner and doc.owner != owner:
raise HTTPException(status_code=404, detail="Document not found")
if not doc.owner and doc.session_id:
sess = db.query(_Sess).filter(_Sess.id == doc.session_id).first()
if sess and sess.owner and sess.owner != owner:
raise HTTPException(status_code=404, detail="Document not found")
lang = (doc.language or "text").strip().lower()
ext = {
"markdown": "md",
"email": "eml",
"json": "json",
"yaml": "yaml",
"yml": "yml",
"html": "html",
"csv": "csv",
"xml": "xml",
"text": "txt",
}.get(lang, "txt")
base = _safe_compose_filename(doc.title or "document", "document")
if not base.lower().endswith(f".{ext}"):
base = f"{base}.{ext}"
return {"filename": base, "content": (doc.current_content or "").encode("utf-8")}
if kind == "gallery":
img = db.query(_GI).filter(_GI.id == item_id, _GI.is_active == True).first()
if not img:
raise HTTPException(status_code=404, detail="Image not found")
if owner and img.owner and img.owner != owner:
raise HTTPException(status_code=404, detail="Image not found")
from routes.gallery.gallery_routes import _gallery_image_path
src = _gallery_image_path(img.filename)
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Image file not found")
return {"filename": _safe_compose_filename(img.filename or "gallery-image.png"), "path": src}
raise HTTPException(status_code=400, detail="Unknown attachment kind")
@router.post("/compose-from-odysseus")
async def compose_from_odysseus(data: dict, owner: str = Depends(require_owner)):
"""Stage an Odysseus document or gallery image as a compose upload."""
kind = str(data.get("kind") or "").strip().lower()
item_id = str(data.get("id") or "").strip()
if kind not in {"document", "gallery"} or not item_id:
raise HTTPException(status_code=400, detail="Expected kind and id")
try:
from core.database import SessionLocal as _SL
db = _SL()
try:
src = _load_odysseus_attachment_source(db, kind, item_id, owner)
if "path" in src:
return _stage_compose_file(src["filename"], src["path"])
return _stage_compose_bytes(src["filename"], src["content"])
finally:
db.close()
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to stage Odysseus attachment {kind}/{item_id}: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.post("/compose-from-odysseus-zip")
async def compose_from_odysseus_zip(data: dict, owner: str = Depends(require_owner)):
"""Stage several Odysseus documents/gallery images as one zip attachment."""
raw_items = data.get("items") or []
if not isinstance(raw_items, list) or not raw_items:
raise HTTPException(status_code=400, detail="Expected items")
if len(raw_items) > 100:
raise HTTPException(status_code=400, detail="Too many attachments")
try:
from core.database import SessionLocal as _SL
db = _SL()
try:
buf = io.BytesIO()
used_names: dict[str, int] = {}
def unique_name(name: str) -> str:
safe = _safe_attachment_zip_name(name, "attachment")
stem = Path(safe).stem or "attachment"
suffix = Path(safe).suffix
idx = used_names.get(safe, 0)
used_names[safe] = idx + 1
if idx == 0:
return safe
return f"{stem}-{idx + 1}{suffix}"
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for item in raw_items:
if not isinstance(item, dict):
continue
kind = str(item.get("kind") or "").strip().lower()
item_id = str(item.get("id") or "").strip()
if kind not in {"document", "gallery"} or not item_id:
continue
src = _load_odysseus_attachment_source(db, kind, item_id, owner)
zname = unique_name(src["filename"])
if "path" in src:
zf.write(src["path"], arcname=zname)
else:
zf.writestr(zname, src["content"])
content = buf.getvalue()
if not content:
raise HTTPException(status_code=400, detail="No valid attachments")
return _stage_compose_bytes("odysseus-attachments.zip", content)
finally:
db.close()
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to stage Odysseus zip attachment: {e}")
return {"success": False, "error": "Mail operation failed"}
@router.post("/compose-from-attachment/{uid}/{index}")
async def compose_from_attachment(
uid: str,
@@ -4423,7 +4703,9 @@ def setup_email_routes():
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_translate"] = bool(settings.get("email_auto_translate", 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
cfg["email_translate_language"] = settings.get("email_translate_language", "English")
return cfg
@@ -4438,11 +4720,9 @@ def setup_email_routes():
"""
# Automation flags stay in settings.json (they're global, not per-account)
settings = _load_settings()
for key in ["email_auto_summarize", "email_auto_reply", "email_auto_tag", "email_auto_spam", "email_auto_calendar", "email_auto_translate"]:
for key in ["email_auto_summarize", "email_auto_reply", "email_auto_tag", "email_auto_spam", "email_auto_calendar"]:
if key in data:
settings[key] = data[key]
if "email_translate_language" in data:
settings["email_translate_language"] = (data.get("email_translate_language") or "English").strip() or "English"
_save_settings(settings)
# Credentials go into the default account row
+12 -3
View File
@@ -191,7 +191,7 @@ def setup_hwfit_routes():
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
@router.get("/models")
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, refresh_catalog: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
"""Rank LLM models against detected hardware and return scored results.
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
active group). gpu_group: index into system.gpu_groups (the homogeneous
@@ -200,11 +200,17 @@ def setup_hwfit_routes():
fresh=true bypasses the hardware-detection cache."""
from services.hwfit.hardware import detect_system
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models, model_catalog_path
from services.hwfit.models import get_models, model_catalog_path, refresh_dynamic_catalogs
host, ssh_port = _validate_detection_target(host, ssh_port)
system = deepcopy(detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh))
if system.get("error"):
return {"system": system, "models": [], "error": system["error"]}
catalog_refresh = None
if refresh_catalog:
try:
catalog_refresh = refresh_dynamic_catalogs(force=True)
except Exception as e:
catalog_refresh = {"error": str(e)}
if not get_models():
return {
"system": system,
@@ -304,7 +310,10 @@ def setup_hwfit_routes():
rank_kwargs.pop("target_context", None)
rank_kwargs.pop("fit_only", None)
results = rank_models(system, **rank_kwargs)
return {"system": system, "models": results}
payload = {"system": system, "models": results}
if catalog_refresh is not None:
payload["catalog_refresh"] = catalog_refresh
return payload
@router.get("/profiles")
def get_serve_profiles(model: str = "", model_path: str = "", host: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, serve_weights_gb: float = 0.0, serve_quant: str = ""):
+42 -4
View File
@@ -1152,6 +1152,36 @@ def _merge_model_ids(*lists):
return out
def _is_mlx_deepseek_v4_repo_id(model_id: str) -> bool:
m = str(model_id or "").lower()
return "mlx-community/deepseek-v4" in m
def _is_mlx_deepseek_v4_shim_id(model_id: str) -> bool:
m = str(model_id or "").lower()
return "/.cache/odysseus/mlx-shims/deepseek-v4" in m
def _filter_mlx_deepseek_v4_repo_when_shimmed(model_ids):
"""Hide the broken MLX repo id when a launch-specific shim id is available.
mlx_lm.server may advertise the original HF repo id even though generation
only works through Odysseus' sanitized local shim. Keep the shim as the
submitted model id and remove the raw repo id from the picker/default list.
"""
ids = list(model_ids or [])
has_shim = any(_is_mlx_deepseek_v4_shim_id(m) for m in ids)
if not has_shim:
return ids
return [m for m in ids if not _is_mlx_deepseek_v4_repo_id(m)]
def _model_display_name(model_id: str) -> str:
if _is_mlx_deepseek_v4_shim_id(model_id):
return str(model_id or "").rstrip("/").split("/")[-1] or "DeepSeek-V4-Flash-4bit"
return str(model_id or "").split("/")[-1]
def _visible_models(cached_models, hidden_models, pinned_models=None):
"""Merge cached + pinned model IDs, then filter out hidden ones.
@@ -1165,6 +1195,7 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
_normalize_model_ids(cached_models),
_normalize_model_ids(pinned_models),
)
merged = _filter_mlx_deepseek_v4_repo_when_shimmed(merged)
if not hidden_models:
return merged
hidden = set(_normalize_model_ids(hidden_models))
@@ -1396,9 +1427,9 @@ def setup_model_routes(model_discovery):
"port": 0,
"url": chat_url,
"models": curated,
"models_display": [mid.split("/")[-1] for mid in curated],
"models_display": [_model_display_name(mid) for mid in curated],
"models_extra": extra,
"models_extra_display": [mid.split("/")[-1] for mid in extra],
"models_extra_display": [_model_display_name(mid) for mid in extra],
"endpoint_id": ep.id,
"endpoint_name": ep.name,
"category": category,
@@ -1426,7 +1457,7 @@ def setup_model_routes(model_discovery):
return {"hosts": [], "items": items}
@router.get("/models")
def api_models(request: Request, refresh: bool = False, background: bool = True):
def api_models(request: Request, refresh: bool = False, background: bool = False):
"""Get available models — per-user (caller sees only their endpoints +
legacy/shared null-owner rows). Cached per-user for 30s."""
# Require auth; "" is the unconfigured single-user mode, treated as
@@ -1887,7 +1918,14 @@ def setup_model_routes(model_discovery):
if api_key.strip() and not existing.api_key:
existing.api_key = api_key.strip()
changed = True
if should_probe:
# Keep duplicate endpoint registration cheap. This path is hit
# by Cookbook/browser auto-register flows and can run while the
# user is sending a chat message. Probing a stale LAN endpoint
# here used to hold the request open for tens of seconds and
# contend with session creation, making "send" feel blocked.
# Explicit "require models" calls still probe; normal refresh
# belongs to /model-endpoints/{id}/models or /probe.
if require_model_list:
probed_models = _probe_endpoint(
base_url,
(api_key.strip() or existing.api_key or None),
+3 -2
View File
@@ -207,6 +207,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
"""Setup session routes with the provided manager and config"""
REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", 20)
SESSION_MODEL_VALIDATION_TIMEOUT = min(float(REQUEST_TIMEOUT or 20), 3.0)
OPENAI_API_KEY = config.get("OPENAI_API_KEY")
SESSIONS_FILE = config.get("SESSIONS_FILE")
@@ -374,7 +375,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
from src.llm_core import list_model_ids
ids = list_model_ids(
endpoint_url,
timeout=REQUEST_TIMEOUT,
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
@@ -394,7 +395,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
req_base = _os.path.basename(model_to_use.rstrip("/"))
avail = list_model_ids(
endpoint_url,
timeout=REQUEST_TIMEOUT,
timeout=SESSION_MODEL_VALIDATION_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
+101 -13
View File
@@ -164,6 +164,8 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
return bool(binaries.get("llama-server") or dists.get("llama-cpp-python"))
if name == "sglang":
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 == "diffusers":
return bool(
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
@@ -210,6 +212,10 @@ 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 == "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 in dists:
return f"{name} {dists[name]}"
return ""
@@ -307,12 +313,14 @@ dist_names={{
'vllm':['vllm'],
'llama_cpp':['llama-cpp-python'],
'sglang':['sglang'],
'mlx_lm':['mlx-lm'],
'diffusers':['diffusers','torch'],
'hf_transfer':['hf-transfer','hf_transfer'],
}}
bin_names={{
'vllm':['vllm'],
'llama_cpp':['llama-server'],
'tmux':['tmux'],
}}
def add_user_install_bins_to_path():
@@ -325,6 +333,8 @@ def add_user_install_bins_to_path():
candidates.append(os.path.expanduser('~/llama.cpp/build/bin'))
candidates.append(os.path.expanduser('~/llama.cpp/build-vulkan/bin'))
candidates.append(os.path.expanduser('~/.local/bin'))
candidates.append('/opt/homebrew/bin')
candidates.append('/usr/local/bin')
parts = os.environ.get('PATH', '').split(os.pathsep) if os.environ.get('PATH') else []
changed = False
for path in reversed([p for p in candidates if p]):
@@ -399,6 +409,47 @@ class ShellExecRequest(BaseModel):
use_tmux: bool = False # run in tmux session (survives browser disconnect)
_REMOTE_TMUX_PATH_PREFIX = 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; '
def _normalize_legacy_remote_tmux_exec(command: str) -> str:
"""Repair stale frontend Cookbook tmux SSH commands.
Older loaded JS sends `ssh host 'tmux capture-pane ...'`. On macOS/Homebrew
remotes, non-login SSH shells often lack /opt/homebrew/bin, so tmux is
installed but the capture/kill command returns nothing. Keep this narrowly
scoped to SSH commands whose remote shell starts with `tmux `.
"""
cmd = command or ""
if _REMOTE_TMUX_PATH_PREFIX in cmd or not cmd.lstrip().startswith("ssh "):
return cmd
try:
parts = shlex.split(cmd)
except Exception:
return cmd
if not parts or parts[0] != "ssh":
return cmd
remote_idx = -1
i = 1
while i < len(parts):
part = parts[i]
if part in {"-p", "-o", "-i", "-F", "-J", "-l", "-S", "-W", "-b", "-c", "-m"}:
i += 2
continue
if part.startswith("-"):
i += 1
continue
remote_idx = i
break
if remote_idx < 0 or remote_idx + 1 >= len(parts):
return cmd
remote_cmd = " ".join(parts[remote_idx + 1:]).strip()
if not remote_cmd.startswith("tmux "):
return cmd
repaired = parts[:remote_idx + 1] + [_REMOTE_TMUX_PATH_PREFIX + remote_cmd]
return shlex.join(repaired)
async def _create_shell(command: str, **kwargs):
"""Spawn a shell subprocess for `command`.
@@ -815,6 +866,10 @@ def setup_shell_routes() -> APIRouter:
if not cmd:
return {"stdout": "", "stderr": "No command provided", "exit_code": 1}
fixed_cmd = _normalize_legacy_remote_tmux_exec(cmd)
if fixed_cmd != cmd:
logger.info("Rewrote legacy remote tmux exec command with Homebrew PATH")
cmd = fixed_cmd
logger.info("User shell exec requested: length=%d", len(cmd))
result = await _exec_shell(
cmd, timeout=req.timeout if req.timeout is not None else EXEC_TIMEOUT
@@ -1134,6 +1189,13 @@ def setup_shell_routes() -> APIRouter:
"category": "LLM",
"target": "remote",
},
{
"name": "mlx_lm",
"pip": "mlx-lm",
"desc": "Serve MLX-format models on Apple Silicon Macs",
"category": "LLM",
"target": "remote",
},
{
"name": "APFEL",
"pip": "",
@@ -1279,9 +1341,9 @@ def setup_shell_routes() -> APIRouter:
for name in all_system_names:
qn = shlex.quote(name)
checks.append(
f"if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi"
f"PATH=\"$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"; if command -v {qn} >/dev/null 2>&1; then echo {qn}=1; else echo {qn}=0; fi"
)
checks.append("echo '---OSREL---'; cat /etc/os-release 2>/dev/null || true")
checks.append("echo '---OSREL---'; cat /etc/os-release 2>/dev/null || { [ \"$(uname -s 2>/dev/null)\" = \"Darwin\" ] && echo ID=macos; } || true")
inner = " ; ".join(checks)
argv = _ssh_base_argv(host, ssh_port) + [inner]
proc = await asyncio.create_subprocess_exec(
@@ -1538,6 +1600,7 @@ def setup_shell_routes() -> APIRouter:
"onnxruntime",
"hdbscan",
"vllm",
"mlx-lm",
}
if pip_name not in known:
return {"ok": False, "error": f"Unknown package: {pip_name}"}
@@ -1584,6 +1647,19 @@ def setup_shell_routes() -> APIRouter:
elif n == "g++": out += ["gcc-c++"]
else: out.append(n)
return out
def _apk(names):
out = []
for n in names:
if n == "build-essential": out.append("build-base")
else: out.append(n)
return out
def _zypper(names):
out = []
for n in names:
if n == "build-essential": out += ["gcc-c++", "make"]
elif n == "g++": out.append("gcc-c++")
else: out.append(n)
return out
def _brew(names):
return [n for n in names if n not in ("build-essential", "g++", "gcc", "make")]
# Build a single shell snippet that detects the package manager and
@@ -1592,6 +1668,8 @@ def setup_shell_routes() -> APIRouter:
apt_pkgs = " ".join(shlex.quote(p) for p in _apt(pkgs))
pac_pkgs = " ".join(shlex.quote(p) for p in _pacman(pkgs))
dnf_pkgs = " ".join(shlex.quote(p) for p in _dnf(pkgs))
apk_pkgs = " ".join(shlex.quote(p) for p in _apk(pkgs))
zypper_pkgs = " ".join(shlex.quote(p) for p in _zypper(pkgs))
brew_pkgs = " ".join(shlex.quote(p) for p in _brew(pkgs))
# Error messages go to stderr (>&2) so the route's error field
# gets populated. Without the redirect, `echo "ERROR…"` on stdout
@@ -1599,18 +1677,28 @@ def setup_shell_routes() -> APIRouter:
# bare "HTTP 200" instead of surfacing the real reason.
script = (
'set -e; '
'if ! sudo -n true 2>/dev/null; then '
' echo "ERROR: passwordless sudo unavailable on this target. Run once: sudo apt install -y ' + " ".join(pkgs) + ' (or your distro equivalent: pacman -S, dnf install, brew install). After that, Cookbook can install the rest." >&2; exit 2; fi; '
'if command -v apt-get >/dev/null 2>&1; then '
f' sudo -n env DEBIAN_FRONTEND=noninteractive apt-get update -qq && sudo -n env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {apt_pkgs}; '
'elif command -v pacman >/dev/null 2>&1; then '
f' sudo -n pacman -Sy --needed --noconfirm {pac_pkgs}; '
'elif command -v dnf >/dev/null 2>&1; then '
f' sudo -n dnf install -y {dnf_pkgs}; '
'elif command -v brew >/dev/null 2>&1; then '
f' brew install {brew_pkgs}; '
'BREW="$(command -v brew 2>/dev/null || true)"; '
'if [ -z "$BREW" ] && [ -x /opt/homebrew/bin/brew ]; then BREW=/opt/homebrew/bin/brew; fi; '
'if [ -z "$BREW" ] && [ -x /usr/local/bin/brew ]; then BREW=/usr/local/bin/brew; fi; '
'if [ -n "$BREW" ]; then '
f' if [ -z "{brew_pkgs}" ]; then echo "Nothing to install with brew for requested packages." >&2; exit 4; fi; "$BREW" install {brew_pkgs}; exit $?; '
'fi; '
'if [ "$(id -u)" = "0" ]; then SUDO=""; '
'elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then SUDO="sudo -n"; '
'else '
' echo "ERROR: no supported package manager (apt/pacman/dnf/brew) on this target." >&2; exit 3; fi'
' echo "ERROR: this target needs sudo for its OS package manager, but passwordless sudo is unavailable. Open a terminal on the target and run the shown install command once, then retry in Cookbook." >&2; exit 2; fi; '
'if command -v apt-get >/dev/null 2>&1; then '
f' $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq && $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends {apt_pkgs}; '
'elif command -v pacman >/dev/null 2>&1; then '
f' $SUDO pacman -Sy --needed --noconfirm {pac_pkgs}; '
'elif command -v dnf >/dev/null 2>&1; then '
f' $SUDO dnf install -y {dnf_pkgs}; '
'elif command -v apk >/dev/null 2>&1; then '
f' $SUDO apk add --no-interactive {apk_pkgs}; '
'elif command -v zypper >/dev/null 2>&1; then '
f' $SUDO zypper --non-interactive install {zypper_pkgs}; '
'else '
' echo "ERROR: no supported package manager (apt/pacman/dnf/apk/zypper/brew) on this target." >&2; exit 3; fi'
)
try:
if host:
File diff suppressed because it is too large Load Diff
+117 -19
View File
@@ -168,6 +168,19 @@ def _canonical_cpu_backend(system):
return "cpu_x86"
def _is_mlx_model(model, native_q=None):
name = (model.get("name") or "").lower()
provider = (model.get("provider") or "").lower()
fmt = (model.get("format") or "").lower()
q = (native_q if native_q is not None else _native_quant(model)).lower()
return (
q.startswith("mlx-")
or provider == "mlx-community"
or fmt == "mlx"
or name.startswith("mlx-community/")
)
def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
"""Estimate tok/s. Uses active params for MoE (only active experts run per token).
@@ -313,6 +326,22 @@ def _fit_score(required, available):
return 50
def _is_unified_memory_system(system):
backend = (system.get("backend") or "").lower()
return bool(system.get("unified_memory")) or backend in ("metal", "mps", "apple")
def _fit_level_for_budget(required_gb, budget_gb):
if not required_gb or not budget_gb or required_gb > budget_gb:
return "too_tight"
ratio = required_gb / budget_gb
if ratio <= 0.50:
return "perfect"
if ratio <= 0.78:
return "good"
return "marginal"
def _context_score(ctx, use_case):
target = CONTEXT_TARGET.get(use_case, 4096)
if ctx >= target:
@@ -516,21 +545,42 @@ def analyze_model(model, system, target_quant=None, scoring_use_case=None, targe
run_mode, quant, fit_ctx, required_gb = result
# Determine fit level
budget = effective_vram if run_mode == "gpu" else available_ram
unified_memory = _is_unified_memory_system(system)
total_ram = system.get("total_ram_gb") or available_ram
unified_budget = max(total_ram or 0, available_ram or 0, effective_vram or 0)
budget = unified_budget if unified_memory else (effective_vram if run_mode == "gpu" else available_ram)
if required_gb > budget:
return None
if run_mode == "gpu":
rec = model.get("recommended_ram_gb") or required_gb
if rec <= gpu_vram:
fit_level = "perfect"
elif gpu_vram >= required_gb * 1.2:
fit_level = "good"
if unified_memory:
fit_level = _fit_level_for_budget(required_gb, budget)
else:
fit_level = "marginal"
# GPU-only fit must leave real allocator/KV/runtime headroom. The
# old check used recommended_ram_gb (or required_gb as a fallback),
# which made any model that barely fit VRAM read as "perfect".
# On CUDA/vLLM/SGLang that is misleading: 141 GB on a 160 GB box is
# runnable, but not a comfortable perfect fit.
if gpu_vram >= required_gb * 1.50:
fit_level = "perfect"
elif gpu_vram >= required_gb * 1.2:
fit_level = "good"
else:
fit_level = "marginal"
elif run_mode == "cpu_offload":
fit_level = "good" if available_ram >= required_gb * 1.2 else "marginal"
fit_level = _fit_level_for_budget(required_gb, budget)
if fit_level == "perfect":
fit_level = "good"
else:
fit_level = "marginal"
fit_level = _fit_level_for_budget(required_gb, budget)
if fit_level == "too_tight":
fit_level = "marginal"
# Rows that comfortably fit in a huge RAM/unified-memory pool should not all
# look "marginal"; that made 1B-70B CPU/Ollama rows orange on 256 GB systems.
if fit_level == "marginal" and budget and required_gb <= budget * 0.78:
fit_level = "good"
if fit_level == "good" and budget and required_gb <= budget * 0.50 and run_mode != "cpu_offload":
fit_level = "perfect"
# Fraction of the model that spills to CPU RAM (drives the offload speed
# model). When offloading, anything beyond the GPU's VRAM lives in system RAM.
@@ -621,6 +671,40 @@ SORT_KEYS = {
}
def _search_blob(*parts):
text = " ".join(str(p or "") for p in parts).lower()
compact = re.sub(r"[^a-z0-9]+", "", text)
spaced = re.sub(r"[^a-z0-9]+", " ", text).strip()
return f"{text} {spaced} {compact}"
def _matches_search(model, search):
terms = [t for t in re.split(r"\s+", (search or "").strip().lower()) if t]
if not terms:
return True
blob = _search_blob(
model.get("name"),
model.get("provider"),
model.get("architecture"),
model.get("quantization"),
model.get("format"),
model.get("parameter_count"),
)
for term in terms:
norm = re.sub(r"[^a-z0-9]+", "", term)
if term not in blob and (not norm or norm not in blob):
if re.fullmatch(r"\d+(?:\.\d+)?b?", term):
try:
wanted = float(term.rstrip("b"))
actual = params_b(model)
except (TypeError, ValueError):
actual = 0
if wanted > 0 and actual > 0 and abs(actual - wanted) <= max(5.0, wanted * 0.08):
continue
return False
return True
def rank_models(system, use_case=None, limit=50, search=None, sort="score", quant=None, target_context=None, fit_only=False):
"""Rank all models against detected hardware. Returns sorted list of fit results.
@@ -693,10 +777,11 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
for m in models:
native_q = _native_quant(m)
is_mlx = _is_mlx_model(m, native_q)
# MLX needs the mlx_lm runtime, which Odysseus does not generate serve
# commands for. Hide it on every backend, including Metal.
if native_q.startswith("mlx-") or "mlx" in (m.get("name") or "").lower():
# MLX is Apple Silicon-only. It should never appear on CUDA/ROCm/CPU,
# but it is first-class on Metal where mlx_lm.server can serve it.
if is_mlx and not apple_silicon:
continue
# ROCm support for vLLM/SGLang quantized safetensors is too brittle to
@@ -723,7 +808,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
# Windows is the same: Odysseus only supports llama.cpp on Windows,
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
# models without a GGUF source are unservable there.
if (apple_silicon or consumer_amd or is_windows) and not (m.get("is_gguf") or m.get("gguf_sources")):
if (apple_silicon or consumer_amd or is_windows) and not is_mlx and not (m.get("is_gguf") or m.get("gguf_sources")):
continue
# Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.
@@ -741,13 +826,26 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
if quant in ("INT4", "INT8", "W4A16", "W8A8", "W8A16") and native_q != quant:
continue
if search:
name = m.get("name", "").lower()
provider = m.get("provider", "").lower()
if search.lower() not in name and search.lower() not in provider:
continue
if search and not _matches_search(m, search):
continue
result = analyze_model(m, system, target_quant=quant, scoring_use_case=(use_case or "general"), target_context=target_context)
model_quant = quant
# UI "Q4" means the user's looking for a 4-bit fit. On multi-GPU
# CUDA/vLLM/SGLang boxes, many practical 4-bit models are native AWQ
# safetensors, not GGUF Q4_K_M. If we pass Q4_K_M into a prequantized
# AWQ row, analyze_model correctly rejects it as the wrong serving
# format, but the result is confusing: highlighting Quant/Q4 hides the
# exact AWQ rows the machine is built to run. Treat Q4 as AWQ-4bit for
# native AWQ rows only on accelerator servers that can serve them.
if (
quant == "Q4_K_M"
and system.get("gpu_count", 1) >= 2
and not (apple_silicon or consumer_amd or is_windows)
and native_q == "AWQ-4bit"
):
model_quant = native_q
result = analyze_model(m, system, target_quant=model_quant, scoring_use_case=(use_case or "general"), target_context=target_context)
if result is None:
continue
+374
View File
@@ -0,0 +1,374 @@
import json
import os
import re
import time
import urllib.parse
import urllib.request
from email.utils import parsedate_to_datetime
from pathlib import Path
from src.constants import DATA_DIR
HF_COLLECTIONS_URL = "https://huggingface.co/api/collections"
HW_FIT_CACHE_DIR = Path(DATA_DIR) / "hwfit"
MLX_COMMUNITY_CACHE = HW_FIT_CACHE_DIR / "mlx_community_models.json"
HF_COLLECTION_MODELS_CACHE = HW_FIT_CACHE_DIR / "hf_collection_models.json"
HF_COLLECTION_TTL_SECONDS = 24 * 3600
HF_COLLECTION_SOURCES = (
{
"key": "mlx_community",
"owner": "mlx-community",
"provider": "mlx-community",
"repo_prefix": "mlx-community/",
"mlx_only": True,
},
{
"key": "zai_org",
"owner": "zai-org",
"provider": "zai-org",
},
{
"key": "deepseek_ai",
"owner": "deepseek-ai",
"provider": "deepseek-ai",
},
{
"key": "minimax_ai",
"owner": "MiniMaxAI",
"provider": "MiniMaxAI",
},
{
"key": "qwen",
"owner": "Qwen",
"provider": "Qwen",
},
{
"key": "stepfun_ai",
"owner": "stepfun-ai",
"provider": "stepfun-ai",
},
{
"key": "google",
"owner": "google",
"provider": "google",
},
{
"key": "openai",
"owner": "openai",
"provider": "openai",
},
{
"key": "mistralai",
"owner": "mistralai",
"provider": "mistralai",
},
{
"key": "meta_llama",
"owner": "meta-llama",
"provider": "meta-llama",
},
{
"key": "nousresearch",
"owner": "NousResearch",
"provider": "NousResearch",
},
{
"key": "moonshotai",
"owner": "moonshotai",
"provider": "moonshotai",
},
{
"key": "mllama",
"owner": "mllama",
"provider": "mllama",
},
)
def _format_params(raw):
try:
n = int(raw or 0)
except (TypeError, ValueError):
n = 0
if n <= 0:
return "", 0
if n >= 1_000_000_000_000:
return f"{n / 1_000_000_000_000:.3g}T", n
if n >= 1_000_000_000:
return f"{n / 1_000_000_000:.4g}B", n
if n >= 1_000_000:
return f"{n / 1_000_000:.4g}M", n
if n >= 1_000:
return f"{n / 1_000:.4g}K", n
return str(n), n
def _parse_params_from_name(repo_id):
name = (repo_id or "").rsplit("/", 1)[-1]
active = None
m_active = re.search(r"[-_][Aa](\d+(?:\.\d+)?)[Bb](?![a-zA-Z])", name)
if m_active:
active = int(float(m_active.group(1)) * 1_000_000_000)
name = name[: m_active.start()] + name[m_active.end() :]
total = None
for m in re.finditer(r"(\d+(?:\.\d+)?)[Bb](?![a-zA-Z])", name):
total = int(float(m.group(1)) * 1_000_000_000)
break
if total is None:
for m in re.finditer(r"(\d+(?:\.\d+)?)[Mm](?![a-zA-Z])", name):
total = int(float(m.group(1)) * 1_000_000)
break
return total or 0, active
def _infer_quant(repo_id, source):
name = (repo_id or "").rsplit("/", 1)[-1].lower()
if source.get("mlx_only"):
if "8bit" in name or "8-bit" in name:
return "mlx-8bit"
if "6bit" in name or "6-bit" in name:
return "mlx-6bit"
if "5bit" in name or "5-bit" in name:
return "mlx-5bit"
if "3bit" in name or "3-bit" in name:
return "mlx-3bit"
if re.search(r"(^|[-_/])bf16($|[-_/])", name):
return "BF16"
return "mlx-4bit"
if "awq" in name and ("8bit" in name or "8-bit" in name or "int8" in name):
return "AWQ-8bit"
if "awq" in name or "4bit" in name or "4-bit" in name:
return "AWQ-4bit"
if "gptq" in name and ("8bit" in name or "8-bit" in name or "int8" in name):
return "GPTQ-Int8"
if "gptq" in name:
return "GPTQ-Int4"
if "mxfp4" in name or "nvfp4" in name or re.search(r"(^|[-_/])fp4($|[-_/])", name):
return "FP4-MoE-Mixed"
if "mxfp8" in name or re.search(r"(^|[-_/])fp8($|[-_/])", name):
return "FP8-Mixed"
if "gguf" in name or "q4_k" in name or "q4-k" in name:
return "Q4_K_M"
if re.search(r"(^|[-_/])bf16($|[-_/])", name):
return "BF16"
return "BF16"
def _quant_bytes_per_param(quant):
return {
"BF16": 2.2,
"FP8": 1.15,
"FP8-Mixed": 1.15,
"FP4-MoE-Mixed": 0.62,
"AWQ-4bit": 0.62,
"AWQ-8bit": 1.15,
"GPTQ-Int4": 0.62,
"GPTQ-Int8": 1.15,
"Q4_K_M": 0.62,
"mlx-8bit": 1.25,
"mlx-6bit": 0.95,
"mlx-5bit": 0.82,
"mlx-4bit": 0.70,
"mlx-3bit": 0.55,
}.get(quant, 2.2)
def _infer_context(repo_id, pipeline_tag):
text = f"{repo_id or ''} {pipeline_tag or ''}".lower()
if any(k in text for k in ("whisper", "asr", "speech-recognition", "tts", "audio", "image", "video", "diffusion")):
return 4096
if any(k in text for k in ("glm-5.2", "deepseek-v4", "minimax-m3")):
return 1_000_000
if any(k in text for k in ("qwen3", "glm", "deepseek", "minimax")):
return 32768
return 32768
def _infer_use_case(repo_id, pipeline_tag):
text = f"{repo_id or ''} {pipeline_tag or ''}".lower()
if any(k in text for k in ("whisper", "asr", "speech-recognition", "transcrib")):
return "stt"
if any(k in text for k in ("tts", "text-to-speech", "kokoro", "audio")):
return "tts"
if any(k in text for k in ("image-text", "vision", "vlm", "vl-", "ocr", "multimodal")):
return "multimodal"
if any(k in text for k in ("code", "coder")):
return "coding"
if any(k in text for k in ("reason", "thinking", "thinker", "r1")):
return "reasoning"
return "general"
def _entry_from_collection_item(collection, item, source):
repo_id = item.get("id") or ""
if item.get("type") != "model" or not repo_id:
return None
repo_prefix = source.get("repo_prefix")
if repo_prefix and not repo_id.startswith(repo_prefix):
return None
raw_params = item.get("numParameters") or 0
active = None
if not raw_params:
raw_params, active = _parse_params_from_name(repo_id)
param_label, raw_params = _format_params(raw_params)
if not raw_params:
return None
quant = _infer_quant(repo_id, source)
pipeline_tag = item.get("pipeline_tag") or ""
min_ram = round((raw_params / 1_000_000_000) * _quant_bytes_per_param(quant) + 0.8, 1)
last_modified = item.get("lastModified") or collection.get("lastUpdated") or ""
release_date = ""
if last_modified:
try:
release_date = parsedate_to_datetime(last_modified).date().isoformat()
except Exception:
release_date = str(last_modified)[:10]
entry = {
"name": repo_id,
"provider": source.get("provider") or repo_id.split("/", 1)[0],
"parameter_count": param_label,
"parameters_raw": raw_params,
"min_ram_gb": min_ram,
"recommended_ram_gb": round(min_ram * 1.3 + 0.5, 1),
"min_vram_gb": 0.0 if source.get("mlx_only") else min_ram,
"quantization": quant,
"context_length": _infer_context(repo_id, pipeline_tag),
"use_case": _infer_use_case(repo_id, pipeline_tag),
"capabilities": ["mlx"] if source.get("mlx_only") else ["vllm", "sglang"],
"pipeline_tag": pipeline_tag,
"architecture": "",
"hf_downloads": int(item.get("downloads") or 0),
"hf_likes": int(item.get("likes") or 0),
"release_date": release_date,
"format": "mlx" if source.get("mlx_only") else "safetensors",
"collection": collection.get("title") or "",
"description": collection.get("description") or "",
"_discovered": True,
"_source": "hf_collections",
"_source_owner": source.get("owner") or "",
}
if source.get("mlx_only"):
entry["mlx_only"] = True
if quant == "Q4_K_M":
entry["is_gguf"] = True
entry["format"] = "gguf"
entry["capabilities"] = ["llama.cpp"]
if active:
entry["is_moe"] = True
entry["active_parameters"] = active
return entry
def _next_link(header):
if not header:
return None
m = re.search(r'<([^>]+)>;\s*rel="next"', header)
return m.group(1) if m else None
def fetch_collection_models(source, timeout=20, max_pages=20):
params = urllib.parse.urlencode({
"owner": source["owner"],
"limit": "100",
"expand": "true",
})
url = f"{HF_COLLECTIONS_URL}?{params}"
models = {}
pages = 0
while url and pages < max_pages:
req = urllib.request.Request(url, headers={"User-Agent": "odysseus-hwfit/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.load(resp)
url = _next_link(resp.headers.get("Link"))
pages += 1
if not isinstance(payload, list):
break
for collection in payload:
if not isinstance(collection, dict):
continue
for item in collection.get("items") or []:
if not isinstance(item, dict):
continue
entry = _entry_from_collection_item(collection, item, source)
if entry and entry["name"] not in models:
models[entry["name"]] = entry
rows = list(models.values())
rows.sort(key=lambda x: (x.get("hf_downloads") or 0, x.get("release_date") or ""), reverse=True)
return rows
def _load_cache(path):
try:
with path.open(encoding="utf-8") as f:
data = json.load(f)
rows = data.get("models") if isinstance(data, dict) else data
return rows if isinstance(rows, list) else []
except (OSError, ValueError):
return []
def _write_cache(path, source, rows):
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"source": source,
"fetched_at": int(time.time()),
"count": len(rows),
"models": rows,
}
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def load_cached_mlx_community_models():
return _load_cache(MLX_COMMUNITY_CACHE)
def load_cached_hf_collection_models():
return _load_cache(HF_COLLECTION_MODELS_CACHE)
def _cache_fresh(path):
try:
return (time.time() - path.stat().st_mtime) < HF_COLLECTION_TTL_SECONDS
except OSError:
return False
def refresh_mlx_community_cache(force=False):
if not force and _cache_fresh(MLX_COMMUNITY_CACHE):
return load_cached_mlx_community_models()
source = next(s for s in HF_COLLECTION_SOURCES if s["key"] == "mlx_community")
rows = fetch_collection_models(source)
_write_cache(MLX_COMMUNITY_CACHE, "https://huggingface.co/mlx-community/collections", rows)
return rows
def refresh_hf_collection_models_cache(force=False):
if not force and _cache_fresh(HF_COLLECTION_MODELS_CACHE):
return load_cached_hf_collection_models()
rows_by_name = {}
for source in HF_COLLECTION_SOURCES:
if source["key"] == "mlx_community":
continue
try:
for row in fetch_collection_models(source):
rows_by_name.setdefault(row["name"], row)
except Exception:
# Keep partial refreshes useful. A temporary DNS/provider issue for
# one brand should not invalidate the other cached collection rows.
continue
rows = sorted(
rows_by_name.values(),
key=lambda x: (x.get("hf_downloads") or 0, x.get("release_date") or ""),
reverse=True,
)
if rows:
_write_cache(HF_COLLECTION_MODELS_CACHE, "https://huggingface.co/collections", rows)
return rows
return load_cached_hf_collection_models()
+77 -10
View File
@@ -13,7 +13,7 @@ QUANT_BPP = {
"AWQ-4bit": 0.50, "AWQ-8bit": 1.0,
"GPTQ-Int4": 0.50, "GPTQ-Int8": 1.0,
"QAT-INT4": 0.50, "QAT-INT8": 1.0,
"mlx-4bit": 0.55, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
"mlx-3bit": 0.42, "mlx-4bit": 0.55, "mlx-5bit": 0.65, "mlx-6bit": 0.75, "mlx-8bit": 1.0,
# DeepSeek-V4-style mixed: MoE experts in FP4 (bulk), attention + non-
# expert dense in FP8, embeddings/LM head in BF16. By weight count the
# experts dominate so the effective BPP sits closer to FP4 than FP8.
@@ -32,7 +32,7 @@ QUANT_SPEED_MULT = {
"AWQ-4bit": 1.2, "AWQ-8bit": 0.85,
"GPTQ-Int4": 1.2, "GPTQ-Int8": 0.85,
"QAT-INT4": 1.15, "QAT-INT8": 0.85,
"mlx-4bit": 1.15, "mlx-8bit": 0.85, "mlx-6bit": 1.0,
"mlx-3bit": 1.25, "mlx-4bit": 1.15, "mlx-5bit": 1.05, "mlx-6bit": 1.0, "mlx-8bit": 0.85,
"FP4-MoE-Mixed": 1.10, # slightly slower than pure FP4 because of mixed-dtype dispatch
"FP8-Mixed": 0.85,
}
@@ -53,7 +53,7 @@ QUANT_QUALITY_PENALTY = {
# QAT-INT4 build lands far closer to bf16 than a post-training Q4/INT4
# (Google reports near-bf16 quality). Penalize it lightly, not like Q4_K_M.
"QAT-INT4": -1.0, "QAT-INT8": 0.0,
"mlx-4bit": -4.0, "mlx-8bit": -0.5, "mlx-6bit": -1.5,
"mlx-3bit": -8.0, "mlx-4bit": -4.0, "mlx-5bit": -2.5, "mlx-6bit": -1.5, "mlx-8bit": -0.5,
# DeepSeek-V4 mixed: only MoE experts at FP4 (the rest is FP8/BF16),
# so the realized quality is much closer to FP8 than to pure FP4 —
# the activation-sensitive layers stay high-precision. ~0 penalty.
@@ -70,7 +70,7 @@ QUANT_BYTES_PER_PARAM = {
"AWQ-4bit": 0.5, "AWQ-8bit": 1.0,
"GPTQ-Int4": 0.5, "GPTQ-Int8": 1.0,
"QAT-INT4": 0.5, "QAT-INT8": 1.0,
"mlx-4bit": 0.5, "mlx-8bit": 1.0, "mlx-6bit": 0.75,
"mlx-3bit": 0.375, "mlx-4bit": 0.5, "mlx-5bit": 0.625, "mlx-6bit": 0.75, "mlx-8bit": 1.0,
"FP4-MoE-Mixed": 0.55,
"FP8-Mixed": 1.0,
}
@@ -87,8 +87,11 @@ PREQUANTIZED_PREFIXES = (
def infer_quantization_from_name(name):
n = (name or "").lower()
model_name = n.rsplit("/", 1)[-1]
if "nvfp4" in n:
return "NVFP4"
if re.search(r"(^|[-_/])bf16($|[-_/])", model_name):
return "BF16"
if "mxfp4" in n:
return "MXFP4"
if re.search(r"(^|[-_/])nf4($|[-_/])", n):
@@ -106,8 +109,12 @@ def infer_quantization_from_name(name):
return "AWQ-8bit" if is8 else "AWQ-4bit"
if "gptq" in n:
return "GPTQ-Int8" if is8 else "GPTQ-Int4"
if "mlx" in n:
if "6bit" in n:
if n.startswith("mlx-community/") or "mlx" in model_name:
if "3bit" in model_name:
return "mlx-3bit"
if "5bit" in model_name:
return "mlx-5bit"
if "6bit" in model_name:
return "mlx-6bit"
return "mlx-8bit" if is8 else "mlx-4bit"
if "fp8" in n:
@@ -260,15 +267,75 @@ def infer_use_case(model):
_models_cache = None
def _load_model_file(path):
try:
with open(path, encoding="utf-8") as f:
loaded = json.load(f)
return loaded if isinstance(loaded, list) else []
except (FileNotFoundError, json.JSONDecodeError):
return []
def reset_model_cache():
global _models_cache
_models_cache = None
def refresh_dynamic_catalogs(force=False):
"""Refresh API-backed model catalogs and invalidate the merged cache.
The bundled JSON files remain the offline fallback. Dynamic catalogs live
under DATA_DIR so runtime refreshes do not dirty the source tree.
"""
from services.hwfit.hf_discovery import (
refresh_hf_collection_models_cache,
refresh_mlx_community_cache,
)
refreshed = {
"mlx_community": len(refresh_mlx_community_cache(force=force)),
"hf_collections": len(refresh_hf_collection_models_cache(force=force)),
}
reset_model_cache()
return refreshed
def get_models():
global _models_cache
if _models_cache is None:
data_path = os.path.join(os.path.dirname(__file__), "data", "hf_models.json")
static_mlx_path = os.path.join(os.path.dirname(__file__), "data", "mlx_community_models.json")
try:
with open(data_path, encoding="utf-8") as f:
_models_cache = [_normalize_model_entry(m) for m in json.load(f)]
except (FileNotFoundError, json.JSONDecodeError):
_models_cache = []
from services.hwfit.hf_discovery import (
load_cached_hf_collection_models,
load_cached_mlx_community_models,
)
dynamic_mlx_models = load_cached_mlx_community_models()
dynamic_hf_models = load_cached_hf_collection_models()
except Exception:
dynamic_mlx_models = []
dynamic_hf_models = []
seen = set()
rows = []
def _append_models(models):
for model in models:
if not isinstance(model, dict):
continue
name = model.get("name")
if not name or name in seen:
continue
seen.add(name)
rows.append(_normalize_model_entry(model))
for model in _load_model_file(data_path):
if not isinstance(model, dict):
continue
name = model.get("name")
if not name or name in seen:
continue
seen.add(name)
rows.append(_normalize_model_entry(model))
_append_models(dynamic_hf_models)
_append_models(dynamic_mlx_models)
_append_models(_load_model_file(static_mlx_path))
_models_cache = rows
return _models_cache
+15 -2
View File
@@ -92,8 +92,21 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
# Deep research jobs, not quick conceptual mentions of research.
("web", "explicit web search request", rf"{_PLEASE}(?:do|run|use|perform|make)\s+(?:a\s+)?(?:web\s+search|search\s+the\s+web)\b.+"),
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
("web", "generic search request", rf"{_PLEASE}search\s+(?!(?:my\s+)?(?:chats?|history|sessions?|notes?|todos?|emails?|mail|inbox|documents?|docs|gallery|images?|files?)\b).+"),
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google(?:\s+it)?)\b.*"),
("web", "short web lookup follow-up", rf"{_PLEASE}(?:just\s+)?(?:look\s+it\s+up|look\s+up|search\s+(?:online|web|now)|search\s+it)\b\s*$"),
("web", "assistant short web lookup request", rf"{_ACTION_QUESTION}(?:search|look\s+up|google)(?:\s+(?:online|web|now|it))?\b.*"),
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google(?:\s+it)?)\b.*"),
("web", "assistant weather check request", rf"{_ACTION_QUESTION}(?:check|find|get|look\s+up)\b.{{0,100}}\b(?:weather|forecast)\b.*"),
("web", "news lookup request", r"\b(?:news|headlines)\s+(?:in|from|about|for)\s+[\w\s.-]{2,80}\??\s*$"),
("web", "forecast lookup request", r"\b(?:hourly|daily|weekly|local)\s+(?:weather\s+)?forecast\b|\b(?:weather\s+)?forecast\s+(?:for|today|tomorrow|now|hourly)\b"),
("web", "weather lookup request", r"\bweather\b.{0,80}\b(?:hourly|rain|raining|rin|today|tomorrow|update|current|now)\b|\b(?:hourly|rain|raining|rin)\b.{0,80}\bweather\b"),
("web", "rain lookup request", r"\b(?:hourly|daily|weekly|local|today|tomorrow|current|now|update)\b.{0,100}\b(?:rain|raining|rainy|precipitation|showers?)\b|\b(?:rain|raining|rainy|precipitation|showers?)\b.{0,100}\b(?:hourly|daily|weekly|local|today|tomorrow|current|now|update|in|for|at)\b"),
("web", "bare weather lookup request", r"\b(?:weather|forecast)\s+(?:in|for|at)?\s*[\w\s.-]{2,80}\??\s*$|\b[\w\s.-]{2,80}\s+(?:weather|forecast)\??\s*$"),
("web", "latest info lookup request", r"\b(?:latest|current|newest|recent|up(?: |-)?to(?: |-)?date)\s+(?:info|information|updates?|details?|developments?)\s+(?:on|about|for|in)\s+[\w\s.,:'\"/-]{2,120}\??\s*$"),
("web", "current/latest lookup request", r"\b(?:current|latest|today'?s?|right\s+now|live|online)\b.{0,120}\b(?:rate|price|news|weather|forecast|score|exchange|market|status)\b"),
("web", "rate/price/news lookup request", r"\b(?:rate|rates|price|prices|news|weather|forecast|score|exchange|currency|market)\b.{0,120}\b(?:now|today|current|latest|online|live|search|look\s+up|find)\b"),
("web", "conversion-rate lookup request", r"\b(?:convert|conversion|exchange)\b.{0,120}\b(?:rate|rates|currency|currencies|price|prices)\b"),
("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+.+"),
+498 -32
View File
@@ -43,6 +43,44 @@ from src.agent_tools import (
logger = logging.getLogger(__name__)
def _looks_like_notes_list_request(text: str) -> bool:
"""Whether the user is asking to see existing notes, not create one."""
t = (text or "").lower()
return bool(
re.search(r"\b(what|show|list|see|current|existing|all|my)\b.{0,60}\bnotes?\b", t)
or re.search(r"\bnotes?\b.{0,60}\b(what|show|list|see|current|existing|all|my)\b", t)
)
def _note_list_summary_from_tool_output(raw: str, max_items: int = 20) -> str:
"""Format manage_notes list/search output for chat without an LLM pass."""
if not isinstance(raw, str) or not raw.strip():
return ""
titles: list[str] = []
for line in raw.splitlines():
m = re.match(r"^\s*-\s+\[[^\]]+\]\s+\*\*(.*?)\*\*(.*)$", line)
if not m:
continue
title = re.sub(r"\s+", " ", m.group(1)).strip()
suffix = re.sub(r"\s+", " ", m.group(2) or "").strip()
label = f"{title} {suffix}".strip()
if label:
titles.append(label)
if len(titles) >= max_items:
break
if not titles:
if re.search(r"\b(no notes|0 notes|found 0)\b", raw, re.IGNORECASE):
return "No notes found."
return ""
total = len(re.findall(r"^\s*-\s+\[[^\]]+\]\s+\*\*", raw, re.MULTILINE))
heading_count = total or len(titles)
lines = [f"Here are your notes ({heading_count}):"]
lines.extend(f"- {title}" for title in titles)
if total and total > len(titles):
lines.append(f"- ...and {total - len(titles)} more")
return "\n".join(lines)
def _load_mcp_disabled_map() -> Dict[str, set]:
"""Load per-server disabled tool sets from the database."""
from core.database import McpServer, SessionLocal
@@ -73,9 +111,11 @@ _AGENT_RULES = """\
## Rules
- Only use tools when needed. Don't search for things you already know.
- For web lookup/search/latest/current requests, use `web_search` or `web_fetch`. Do NOT use `bash`, `python`, `curl`, `requests`, or scraping code for web lookup unless web tools are disabled or already failed.
- If `web_search` is listed in this prompt, web search is available. Do NOT tell the user search/web tools are unavailable.
- These exact tags execute automatically. For showing code examples, use ```shell, ```sh, ```py, etc. instead.
- Multiple tool blocks per response OK. 60s timeout per tool, 10K char output limit.
- Code/content >15 lines ```create_document (NOT in chat). Short snippets OK in chat.
- Long-form or structured writing is a document by default when the user asks to write/create/make/generate it and the answer would be more than a short paragraph. Use create_document instead of dumping the full content in chat.
- Editing an existing document: ALWAYS use ```edit_document with FIND/REPLACE blocks. Do NOT rewrite the whole document with ```update_document unless genuinely changing more than half of it.
- BIAS TOWARD ACTION on edit requests. If the user says "edit out X", "remove the Y paragraph", "change Z" JUST DO IT with your best interpretation. Don't ask for clarification on minor ambiguity. The user can undo or re-prompt if wrong.
- AFTER A TOOL SUCCEEDS, do not second-guess. The success message ("Document edited: v2, 1 edit") means it worked. Reply in ONE short sentence confirming what was done. No re-checking, no replaying the diff in your head, no validation theater.
@@ -120,8 +160,10 @@ _API_AGENT_RULES = """\
- Only call tools when they materially help answer the request.
- You MUST use tools to take action do not describe what you would do. Act, don't narrate.
- For web lookup/search/latest/current requests, call `web_search` or `web_fetch`. Do NOT use shell, Python, curl, requests, or scraping code for web lookup unless web tools are unavailable or already failed.
- If `web_search` is listed in this prompt, web search is available. Do NOT tell the user search/web tools are unavailable.
- Keep answers concise unless the user asks for depth.
- For long code or content, use document tools instead of pasting large blocks into chat.
- Long-form or structured writing is a document by default when the user asks to write/create/make/generate it and the answer would be more than a short paragraph. Call create_document instead of dumping the full content in chat.
- Editing an existing document: ALWAYS use `edit_document` with find/replace. Only use `update_document` for genuine full rewrites (>50% changed) do NOT echo the entire file back for small edits.
- If the active editor document is an email draft/compose window, treat that open email as the target for "write this", "write the email", "reply with...", "make it say...", "draft this", and similar requests. Do NOT create another document, search/list/manage documents, or open a different reply unless the user explicitly asks. Edit the open email draft with `edit_document` or `update_document`; preserve To/Cc/Bcc/Subject/In-Reply-To/References/X-* header lines unless the user asks to change them.
- "Give suggestions / feedback / review / how can I improve this / what would make it better" about the OPEN document call `suggest_document`, do NOT write a prose list of ideas in chat. It creates inline accept/reject bubbles on the doc. Give concrete `find`/`replace`/`reason` items. To suggest an ADDITION (e.g. "add a bow to the SVG", a new section), set `find` to a short existing anchor snippet and `replace` to that same snippet PLUS the new content. Only answer in prose when no document is open, or the request is purely conceptual with no concrete change to propose.
@@ -279,7 +321,7 @@ _DOMAIN_RULES = {
}
_DOMAIN_TOOL_MAP = {
"web": {"web_search", "web_fetch", "trigger_research", "manage_research"},
"web": {"web_search", "web_fetch"},
"documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"},
"email": {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "archive_email", "delete_email", "mark_email_read", "resolve_contact", "manage_contact"},
"cookbook": {"download_model", "serve_model", "serve_preset", "list_serve_presets", "list_served_models", "stop_served_model", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_cookbook_servers", "adopt_served_model"},
@@ -337,6 +379,7 @@ Or with JSON for fresh news:
{"query": "<your query>", "time_filter": "day"}
```
Search the web for a SINGLE quick fact/lookup mid-task. For news / "today" / "latest" queries, pass `time_filter` ("day", "week", "month", or "year"). NOT for "research X" / "do research on X" / "look into X" requests those mean a multi-source DEEP RESEARCH job: use `trigger_research` instead (it runs in the Deep Research sidebar and produces a full report). web_search = one quick query; trigger_research = a researched report.
If this `web_search` tool section is visible, search is available. Do NOT tell the user web/search tools are unavailable.
Use this instead of `bash`, `curl`, `python`, `requests`, or scraping code for web lookup/search/latest/current requests.""",
"web_fetch": """\
@@ -614,16 +657,6 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool
if one_liners:
parts.append("## Additional tools\n" + "\n".join(one_liners))
# Mention tools that exist but weren't included
all_known = set(TOOL_SECTIONS.keys())
not_shown = all_known - included - disabled
if not_shown:
sample = sorted(not_shown)[:5]
hint = ", ".join(sample)
if len(not_shown) > 5:
hint += f", ... ({len(not_shown) - 5} more)"
parts.append(f"(Other tools available when needed: {hint})")
parts.append(_AGENT_RULES)
parts.extend(_domain_rules_for_tools(included))
return "\n\n".join(parts)
@@ -763,6 +796,15 @@ def _extract_last_user_message(messages: List[Dict]) -> str:
return ""
def _user_turn_count(messages: List[Dict]) -> int:
"""Count real user turns in the message list."""
count = 0
for msg in messages or []:
if msg.get("role") == "user":
count += 1
return count
def _insert_before_latest_user(messages: List[Dict], context_msg: Dict) -> List[Dict]:
"""Insert a context message immediately before the latest user turn."""
out = list(messages or [])
@@ -973,7 +1015,7 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
domains.add("cookbook")
if has(r"\b(emails?|mails?|gmail|inbox|reply|forward|cc|bcc|send email|compose email|draft email|message chris|message him|message her)\b"):
domains.add("email")
if has(r"\b(note|todo|to-do|checklist|task list|remind me|reminder|buy|pickup|pick up)\b"):
if has(r"\b(notes?|todos?|to-dos?|checklists?|task list|remind me|reminders?|buy|pickup|pick up)\b"):
domains.add("notes_calendar_tasks")
if has(r"\b(every day|every morning|every evening|recurring|automatically|cron|scheduled task|background task)\b"):
domains.add("notes_calendar_tasks")
@@ -1076,6 +1118,20 @@ def _turn_targets_active_document(intent: Dict[str, object], last_user: str, act
text,
):
return True
if re.search(
r"\b(?:make|change|update|fix|edit|rewrite|rework|revise|replace|remove|delete|add|append|insert|set|turn)\b"
r".{0,80}\b(?:day\s*\d+|row|rows|column|columns|table|section|chapter|part|paragraph|line|lines|"
r"title|heading|body|intro|introduction|conclusion|schedule|itinerary|draft|content)\b",
text,
):
return True
if re.search(
r"\b(?:day\s*\d+|row|rows|column|columns|table|section|chapter|part|paragraph|line|lines|"
r"title|heading|body|intro|introduction|conclusion|schedule|itinerary)\b"
r".{0,80}\b(?:make|change|update|fix|edit|rewrite|rework|revise|replace|remove|delete|add|append|insert|set|turn)\b",
text,
):
return True
if re.search(
r"\b(?:add|insert|include|apply|put)\b.+\b(?:to it|to this|there|in it|in this|in the text|in the document)\b",
text,
@@ -1098,6 +1154,18 @@ def _turn_targets_active_document(intent: Dict[str, object], last_user: str, act
))
def _is_email_document_obj(active_document) -> bool:
if active_document is None:
return False
raw_doc = getattr(active_document, "current_content", "") or ""
title_l = (getattr(active_document, "title", "") or "").strip().lower()
return (
getattr(active_document, "language", None) == "email"
or title_l in {"new email", "new mail", "new message"}
or ("To:" in raw_doc[:400] and "Subject:" in raw_doc[:400] and "\n---\n" in raw_doc)
)
def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
facts: List[str] = []
seen = set()
@@ -1125,9 +1193,9 @@ def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
continue
seen.add(fact)
facts.append(fact)
if len(facts) >= 12:
if len(facts) >= 8:
break
if len(facts) >= 12:
if len(facts) >= 8:
break
if not facts:
return None
@@ -1144,6 +1212,41 @@ def _minimal_saved_memory_message(messages: List[Dict]) -> Optional[Dict]:
}
def _compact_email_draft_context(raw: str, *, max_own_chars: int = 1200, max_history_chars: int = 1200) -> str:
"""Compact an email compose document for prompt injection.
The editor/backend preserve quoted history mechanically, so the model only
needs enough of the previous message to understand what to answer.
"""
text = raw or ""
if "\n---\n" not in text:
return text[:3500] + ("\n...[truncated]" if len(text) > 3500 else "")
header, body = text.split("\n---\n", 1)
literal = "---------- Previous message ----------"
idx = body.find(literal)
if idx >= 0:
own = body[:idx].strip()
history = body[idx:].strip()
else:
own = body.strip()
history = ""
if len(own) > max_own_chars:
own = own[:max_own_chars].rstrip() + "\n...[draft body truncated]"
if len(history) > max_history_chars:
history = history[:max_history_chars].rstrip() + "\n...[quoted history truncated; full history is preserved by Odysseus]"
if history:
body_out = (
f"{own}\n\n" if own else ""
) + (
"QUOTED HISTORY EXCERPT FOR CONTEXT ONLY -- do not rewrite or include this excerpt in your tool output; "
"Odysseus preserves the full quoted thread below the reply automatically.\n"
f"{history}"
)
else:
body_out = own
return header.rstrip() + "\n---\n" + body_out.strip()
def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream_create: bool = False) -> List[Dict]:
"""Tiny prompt path for the Odysseus document LoRA.
@@ -1166,6 +1269,10 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream
else:
system = (
"You are Odysseus. Edit or suggest changes to the active document using exactly one fenced tool block when needed.\n"
"The active document content is authoritative. Apply the user's request to that content; do not append the user's instruction as document text.\n"
"Preserve the current title, language, structure, and existing meaning unless the user explicitly asks to change them.\n"
"If the user asks for ALL CAPS/uppercase/lowercase, transform the existing document text itself.\n"
"If the user refers to line numbers, use the numbered active document lines; never include the line numbers or tabs in FIND/REPLACE text.\n"
"If the user asks to add, remove, rewrite, transform, change, capitalize, shorten, expand, or otherwise apply a change, use edit_document or update_document, not suggest_document.\n"
"Use suggest_document only when the user explicitly asks for suggestions, feedback, or proposed improvements without applying them.\n"
"For targeted edits:\n"
@@ -1201,20 +1308,98 @@ def _minimal_odysseus_doc_messages(messages: List[Dict], active_document, stream
out.append(memory_message)
if active_document is not None:
content = active_document.current_content or ""
if not stream_create:
content_for_prompt = "\n".join(
f"{idx}\t{line}" for idx, line in enumerate(content.split("\n"), 1)
)
content_note = (
"Content with line numbers. The number and tab are reference-only and are not part of the document:\n"
)
else:
content_for_prompt = content
content_note = "Content:\n"
out.append({
"role": "user",
"content": (
"Active document:\n"
f"Title: {active_document.title}\n"
f"Language: {active_document.language or 'text'}\n"
"Content:\n"
f"{content}"
f"{content_note}"
f"{content_for_prompt}"
),
})
out.append({"role": "user", "content": latest})
return out
def _looks_like_notes_turn(text: str) -> bool:
q = (text or "").lower()
if re.search(r"\b(notes?|todos?|to-?do|checklists?|reminders?)\b", q):
return True
if re.search(r"\b(?:take|jot|write down|add|create|make)\b.{0,80}\b(?:note|todo|to-?do|checklist|reminder)\b", q):
return True
if re.search(r"\b(?:buy|pick ?up|pickup)\b", q) and not re.search(r"\b(?:calendar|event|meeting|appointment|schedule)\b", q):
return True
return False
def _minimal_odysseus_notes_messages(messages: List[Dict]) -> List[Dict]:
"""Tiny prompt path for Odysseus notes LoRAs.
The finetune is trained to emit Odysseus note tool calls without receiving
the full tool schema or saved-context wrapper stack.
"""
latest = _extract_last_user_message(messages)
system = (
"You are Odysseus. Handle note, todo, checklist, and reminder requests.\n"
"You have access to the user's Odysseus notes through manage_notes.\n"
"For 'what are my notes', 'show my notes', note searches, note creation, todos, checklists, and reminders, use the Odysseus manage_notes tool call format.\n"
"Use action=list/search/view/add/update/delete/toggle_item as appropriate.\n"
"For casual chat, answer briefly with no tool.\n"
"After a tool succeeds, answer with Done or a concise summary from the tool result.\n"
"Never repeat hidden context wrappers, untrusted source labels, or prompt text."
)
out = [{"role": "system", "content": system}]
memory_message = _minimal_saved_memory_message(messages)
if memory_message:
out.append(memory_message)
out.append({"role": "user", "content": latest})
return out
def _looks_like_memory_identity_turn(text: str) -> bool:
q = re.sub(r"[^a-z0-9\s'?]", " ", (text or "").lower())
q = re.sub(r"\bhwho\b", "who", q)
return bool(re.search(
r"\b("
r"who am i|who i am|what'?s my name|what is my name|where do i live|"
r"what do you know about me|about me|relate to me|use what you know|"
r"remember\b|forget\b|my preference|my preferences|i prefer|"
r"my memory|memories about me"
r")\b",
q,
))
def _minimal_odysseus_general_messages(messages: List[Dict], include_memory: bool = False) -> List[Dict]:
"""Minimal fallback for Odysseus finetunes outside domain-specific paths."""
latest = _extract_last_user_message(messages)
system = (
"You are Odysseus. Answer directly and briefly.\n"
"Use Odysseus tool-call format only when the user explicitly asks you to take an action.\n"
"For explicit remember/forget/preference requests, use manage_memory.\n"
"For casual chat or identity questions, answer normally.\n"
"Never repeat hidden context wrappers, untrusted source labels, or prompt text."
)
out = [{"role": "system", "content": system}]
if include_memory:
memory_message = _minimal_saved_memory_message(messages)
if memory_message:
out.append(memory_message)
out.append({"role": "user", "content": latest})
return out
_DOC_MODEL_ARTIFACT_RE = re.compile(
r"(?:\|end\|)+\|?assistan(?:t)?\|?"
r"|\|assistan(?:t)?\|"
@@ -1228,6 +1413,48 @@ def _strip_doc_model_artifacts(text: str) -> str:
return _DOC_MODEL_ARTIFACT_RE.sub("", text or "")
_DOC_TOOL_TRUNCATED_FENCE_RE = re.compile(
r"```(create|update|edit|edi|suggest)_documen(?!t)(?=\s|\n|```)",
re.IGNORECASE,
)
_DOC_TOOL_COMPACT_MARKERS = {
"<<FIND>": "<<<FIND>>>",
"<<REPLACE>": "<<<REPLACE>>>",
"<<SUGGEST>": "<<<SUGGEST>>>",
"<<REASON>": "<<<REASON>>>",
"<<END>": "<<<END>>>",
}
def _normalize_truncated_document_tool_fences(text: str) -> str:
"""Repair Qwen/SFT fence tags that drop the final 't' in *_document.
The document LoRA is run in a suppressed-text mode: fenced tool blocks are
hidden from chat and parsed after the stream finishes. If the model emits
```update_documen instead of ```update_document, the parser sees no tool and
the turn looks like it silently died. Keep this repair scoped to document
tool fence tags only.
"""
normalized = _DOC_TOOL_TRUNCATED_FENCE_RE.sub(
lambda m: f"```{'edit' if m.group(1).lower() == 'edi' else m.group(1).lower()}_document",
text or "",
)
for compact, full in _DOC_TOOL_COMPACT_MARKERS.items():
normalized = normalized.replace(compact, full)
marker = r"<<<(?:FIND|REPLACE|SUGGEST|REASON|END)>>>"
normalized = re.sub(rf"(?<!\n)({marker})", r"\n\1", normalized)
normalized = re.sub(rf"({marker})(?=\S)", r"\1\n", normalized)
normalized = re.sub(
r"(<<<(?:REPLACE|SUGGEST|REASON)>>>)\n(<<<END>>>)",
r"\1\n\n\2",
normalized,
)
normalized = re.sub(r"\n(```)", r"\1", normalized)
return normalized
def _normalize_stream_document_fences(text: str, target_tool: str = "create_document") -> str:
"""Treat visible ```document/documen blocks as document tool blocks.
@@ -1236,7 +1463,9 @@ def _normalize_stream_document_fences(text: str, target_tool: str = "create_docu
the same shape is a full replacement of the open document, so map it to
update_document and drop the title/language header lines.
"""
text = _strip_doc_model_artifacts(text or "")
text = _normalize_truncated_document_tool_fences(
_strip_doc_model_artifacts(text or "")
)
def repl(match: re.Match) -> str:
body = match.group(1) or ""
@@ -1387,6 +1616,7 @@ def _build_system_prompt(
_email_style_message = None
_integ_message = None
_mcp_desc_message = None
_active_doc_is_email_doc = False
if active_document:
set_active_document(active_document.id)
_doc_raw = active_document.current_content or ""
@@ -1402,19 +1632,23 @@ def _build_system_prompt(
or _doc_title_l in {"new email", "new mail", "new message"}
or ("To:" in _doc_raw[:400] and "Subject:" in _doc_raw[:400] and "\n---\n" in _doc_raw)
)
_active_doc_is_email_doc = _is_email_doc
if _is_email_doc:
_email_prompt_doc = _compact_email_draft_context(_doc_raw)
doc_ctx = (
f'ACTIVE EMAIL DRAFT (open in editor — the user is looking at this right now)\n'
f'Title: "{active_document.title}"\n'
f'```\n{_doc_raw}\n```\n\n'
f'```\n{_email_prompt_doc}\n```\n\n'
f'This is the current email compose window, not a normal document library item. If the user says "write", "draft", "reply", "make it say", or "write the email" without naming another target, edit THIS email draft.\n\n'
f'When the user asks you to write, reply to, or improve this email:\n'
f'1. Use `update_document` to replace the ENTIRE content — keep all the header lines (To, Subject, In-Reply-To, References, X-Source-UID, X-Source-Folder, X-Attachments) and the `---` separator EXACTLY as they are.\n'
f'2. Replace ONLY the body text (the part after `---`). If there is a quoted original email (lines starting with `>`), keep that quoted block unchanged BELOW your new reply.\n'
f'1. Use `update_document` to update this email draft — keep all header lines (To, Subject, In-Reply-To, References, X-Source-UID, X-Source-Folder, X-Attachments) and the `---` separator EXACTLY as they are.\n'
f'2. Replace ONLY the new reply text above `---------- Previous message ----------`. You may omit the quoted history from your tool output; Odysseus preserves everything from that separator downward automatically.\n'
f'3. Write the reply body above the quoted original. Use the saved email writing style when present.\n'
f'4. Identity is critical: write as the logged-in user / mailbox owner only. NEVER sign as the recipient, original sender, quoted sender, spouse, assistant, company, or any third party. If adding a signature, use only the name/signature implied by the saved email writing style.\n'
f'5. Mechanical style is critical: never use em dash/en dash; use --. Never use curly apostrophes. For English emails, use Hi/Hiya from the saved style rather than Hey unless the user explicitly asks for Hey.\n'
f'6. Do NOT use create_document — the email is already open, you must update it.\n\n'
f'6. Do NOT use create_document — the email is already open, you must update it.\n'
f'7. Do NOT call read_email/list_emails for this turn. The open email draft above is the source of truth, and the quoted history excerpt is enough context for a reply.\n'
f'8. After a successful tool call, answer with a brief confirmation only. Do not paste the full email back into chat unless the user asks.\n\n'
f'Do NOT ask the user to paste or share the email — you already have it above.'
)
else:
@@ -1528,7 +1762,7 @@ def _build_system_prompt(
# resolve to the real UID instead of the agent inventing a fresh .md
# draft with fake headers. This is the email equivalent of _doc_message.
_email_message = None
if active_email and active_email.get("uid"):
if active_email and active_email.get("uid") and not _active_doc_is_email_doc:
_em_uid = active_email.get("uid", "")
_em_folder = active_email.get("folder", "INBOX")
_em_account = active_email.get("account", "")
@@ -2328,6 +2562,7 @@ async def stream_agent_loop(
workspace: Optional[str] = None,
forced_tools: Optional[Set[str]] = None,
uploaded_files: Optional[List[Dict]] = None,
workload: str = "foreground",
_is_teacher_run: bool = False,
) -> AsyncGenerator[str, None]:
"""Streaming agent loop generator.
@@ -2371,13 +2606,23 @@ async def stream_agent_loop(
_t0 = time.time()
_needs_admin = _detect_admin_intent(messages)
_last_user = _extract_last_user_message(messages)
_ody_qwen_finetune_model = (model or "").lower().startswith("odysseus-qwen3")
_ody_memory_identity_turn = _looks_like_memory_identity_turn(_last_user)
_intent = _classify_agent_request(messages, _last_user)
_low_signal_turn = bool(_intent.get("low_signal"))
_casual_low_signal_turn = _is_casual_low_signal(_last_user)
_existing_conversation = _user_turn_count(messages) > 1
_active_document_relevant = _turn_targets_active_document(_intent, _last_user, active_document)
_active_email_draft_relevant = _active_document_relevant and _is_email_document_obj(active_document)
if _active_email_draft_relevant:
disabled_tools.update({
"list_email_accounts", "list_emails", "read_email",
"mcp__email__list_emails", "mcp__email__read_email",
})
_prompt_active_document = active_document if _active_document_relevant else None
_direct_low_signal = (
_low_signal_turn
and not _existing_conversation
and not bool(_intent.get("continuation"))
and not plan_mode
and not approved_plan
@@ -2400,10 +2645,22 @@ async def stream_agent_loop(
_active_document_relevant,
_retrieval_query[:200],
)
if _low_signal_turn and _existing_conversation:
logger.info(
"[agent] keeping contextual path for low-signal turn in existing conversation latest=%r",
_last_user[:80],
)
_mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {}
if _direct_low_signal:
logger.info("[agent] direct low-signal reply path for latest=%r", _last_user[:80])
direct_messages = [{"role": "user", "content": _last_user}]
direct_messages = (
_minimal_odysseus_general_messages(
messages,
include_memory=True,
)
if _ody_qwen_finetune_model
else [{"role": "user", "content": _last_user}]
)
direct_response = ""
direct_start = time.time()
direct_actual_model = model
@@ -2419,6 +2676,7 @@ async def stream_agent_loop(
tools=None,
timeout=int(get_setting("agent_stream_timeout_seconds", 300) or 300),
session_id=session_id,
workload=workload,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -2511,7 +2769,18 @@ async def stream_agent_loop(
if not guide_only and not _relevant_tools:
try:
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
tool_idx = get_tool_index()
try:
tool_idx = await asyncio.wait_for(
asyncio.to_thread(get_tool_index),
timeout=_TOOL_SELECTION_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning(
"[tool-rag] Tool index init exceeded %.1fs; falling back to always-available tools",
_TOOL_SELECTION_TIMEOUT_SECONDS,
)
tool_idx = None
_relevant_tools = set(ALWAYS_AVAILABLE)
if tool_idx:
if mcp_mgr:
try:
@@ -2579,6 +2848,13 @@ async def stream_agent_loop(
_relevant_tools.add("ui_control")
if "web" in (_intent.get("domains") or set()):
_relevant_tools.update({"web_search", "web_fetch"})
_removed_web_blocks = sorted({"web_search", "web_fetch"} & disabled_tools)
if _removed_web_blocks:
disabled_tools.difference_update({"web_search", "web_fetch"})
logger.info(
"[agent-intent] web turn forced search tools enabled; removed disabled=%s",
_removed_web_blocks,
)
if "ui" in (_intent.get("domains") or set()):
_relevant_tools.add("ui_control")
@@ -2588,6 +2864,19 @@ async def stream_agent_loop(
# panel is open.
if _relevant_tools is not None and _active_document_relevant:
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
if _active_email_draft_relevant:
# The open compose document already contains the recipient,
# subject, source UID, and quoted previous-message excerpt. Reading
# the same email again through IMAP/MCP is slow, token-heavy, and
# can hang. Keep draft editing tools, drop email fetch tools.
_email_fetch_tools = {
"list_email_accounts", "list_emails", "read_email",
"mcp__email__list_emails", "mcp__email__read_email",
}
removed = sorted(_relevant_tools & _email_fetch_tools)
if removed:
_relevant_tools.difference_update(_email_fetch_tools)
logger.info("[agent-intent] active email draft pruned fetch tools=%s", removed)
# Current-turn chat uploads are real files under the upload/data root. Make
# the read-side file/document tools visible immediately so the agent can
@@ -2598,14 +2887,15 @@ async def stream_agent_loop(
_relevant_tools = set(ALWAYS_AVAILABLE)
_relevant_tools.update({"read_file", "grep", "ls", "manage_documents"})
# Per-request UI toggles are stronger than retrieval. If the user turns on
# Search, the model must see the search tools even when the latest text is a
# typo or otherwise low-signal for tool RAG.
# Per-request forced tools are stronger than retrieval. Search toggles and
# explicit lookup turns must make web tools visible even when tool RAG
# misses them; route-level disabled_tools decides what else is allowed.
if not guide_only and forced_tools:
forced_set = {t for t in forced_tools if t not in disabled_tools}
if _relevant_tools is None:
from src.tool_index import ALWAYS_AVAILABLE
_relevant_tools = set(ALWAYS_AVAILABLE)
_relevant_tools.update(t for t in forced_tools if t not in disabled_tools)
_relevant_tools.update(forced_set)
# The skill index injected by _build_system_prompt tells the model to
# call `manage_skills action=view`, and Jaccard-matched skills are pasted
@@ -2647,7 +2937,7 @@ async def stream_agent_loop(
_intent_domains = set(_intent.get("domains") or set())
_ody_doc_finetune_mode = (
(model or "").lower().startswith("odysseus-qwen3")
_ody_qwen_finetune_model
and (
"documents" in _intent_domains
or _active_document_relevant
@@ -2656,6 +2946,14 @@ async def stream_agent_loop(
and "files" not in _intent_domains
and not guide_only
)
_ody_notes_finetune_mode = (
_ody_qwen_finetune_model
and not _ody_doc_finetune_mode
and ("notes_calendar_tasks" in _intent_domains or _looks_like_notes_turn(_last_user))
and _looks_like_notes_turn(_last_user)
and "files" not in _intent_domains
and not guide_only
)
_ody_doc_stream_create_mode = _ody_doc_finetune_mode and _prompt_active_document is None
if _ody_doc_finetune_mode and _relevant_tools is not None:
if _prompt_active_document is not None:
@@ -2666,6 +2964,36 @@ async def stream_agent_loop(
else:
_relevant_tools = {"create_document", "ask_user", "update_plan"}
logger.info("[agent-intent] odysseus doc finetune tool clamp=%s", sorted(_relevant_tools))
elif _ody_notes_finetune_mode and _relevant_tools is not None:
_relevant_tools = {"manage_notes", "ask_user", "update_plan"}
logger.info("[agent-intent] odysseus notes finetune tool clamp=%s", sorted(_relevant_tools))
if (
_relevant_tools is not None
and _active_document_relevant
and "files" not in _intent_domains
and not uploaded_files
and not workspace
):
_doc_irrelevant_file_tools = {
"append_file",
"bash",
"edit_file",
"glob",
"grep",
"ls",
"read_file",
"replace_file",
"run_shell",
"write_file",
}
_removed_doc_file_tools = sorted(_relevant_tools & _doc_irrelevant_file_tools)
if _removed_doc_file_tools:
_relevant_tools.difference_update(_doc_irrelevant_file_tools)
logger.info(
"[agent-intent] active document turn removed file tools=%s",
_removed_doc_file_tools,
)
if _relevant_tools is not None:
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
@@ -2767,6 +3095,24 @@ async def stream_agent_loop(
_ody_doc_stream_create_mode,
len(messages),
)
elif _ody_notes_finetune_mode and not plan_mode and not approved_plan and not guide_only:
messages = _minimal_odysseus_notes_messages(messages)
mcp_schemas = []
logger.info(
"[agent-intent] odysseus notes minimal prompt active messages=%s",
len(messages),
)
elif _ody_qwen_finetune_model and not plan_mode and not approved_plan and not guide_only:
messages = _minimal_odysseus_general_messages(
messages,
include_memory=True,
)
mcp_schemas = []
logger.info(
"[agent-intent] odysseus general minimal prompt active include_memory=%s messages=%s",
_ody_memory_identity_turn,
len(messages),
)
if plan_mode and not guide_only:
# Steer the model to investigate-then-propose. Hard tool gating handles
# every write path except shell; this directive is what keeps the
@@ -2881,6 +3227,7 @@ async def stream_agent_loop(
requested_model = model
actual_model = model
total_tool_calls = 0 # for budget enforcement
_ody_notes_tool_completed = False
# Loop-breaker state. Small models (e.g. deepseek-v4-flash) can get
# stuck firing the same tool call over and over with no text — burns
@@ -2978,7 +3325,7 @@ async def stream_agent_loop(
if s.get("function", {}).get("name") not in _ADMIN_SCHEMA_NAMES
]
all_tool_schemas = base_schemas + mcp_schemas
if _ody_doc_finetune_mode:
if _ody_qwen_finetune_model:
all_tool_schemas = []
if disabled_tools:
all_tool_schemas = [
@@ -3028,6 +3375,7 @@ async def stream_agent_loop(
tool_choice_none=_ody_doc_finetune_mode,
timeout=agent_stream_timeout,
session_id=session_id,
workload=workload,
):
if not _round_first_event_logged:
_round_first_event_logged = True
@@ -3149,11 +3497,15 @@ async def stream_agent_loop(
if data.get("thinking"):
round_reasoning += data["delta"]
else:
_delta_text = _strip_doc_model_artifacts(data["delta"]) if _ody_doc_finetune_mode else data["delta"]
_delta_text = (
_strip_doc_model_artifacts(data["delta"])
if _ody_qwen_finetune_model
else data["delta"]
)
round_response += _delta_text
full_response += _delta_text
data["delta"] = _delta_text
if not _ody_doc_finetune_mode or data.get("thinking"):
if not _ody_qwen_finetune_model or data.get("thinking"):
yield f"data: {json.dumps(data)}\n\n"
# Detect text-fence doc streaming. Normal agent prompts
# use ```create_document; the doc LoRA streaming path
@@ -3274,6 +3626,59 @@ async def stream_agent_loop(
else converted_calls[:1]
)
if _ody_qwen_finetune_model and tool_blocks:
_allowed_memory_write_actions = {"add", "edit", "update", "delete", "delete_all"}
_explicit_memory_browse = bool(re.search(
r"\b(search|list|show|open|view)\b.{0,40}\b(memories|memory|brain)\b",
_last_user.lower(),
))
_filtered_tool_blocks = []
_filtered_converted_calls = []
_dropped_memory_lookup = False
for _idx, _block in enumerate(tool_blocks):
if _block.tool_type != "manage_memory":
_filtered_tool_blocks.append(_block)
if _idx < len(converted_calls):
_filtered_converted_calls.append(converted_calls[_idx])
continue
_action = ""
try:
_args = json.loads(_block.content or "{}")
if isinstance(_args, dict):
_action = str(_args.get("action") or "").lower()
except Exception:
_action = ""
if _action in {"list", "search", "view", "get", "read"} and not _explicit_memory_browse:
_dropped_memory_lookup = True
elif _action in _allowed_memory_write_actions and re.search(
r"\b(remember|forget|preference|prefer|save this about me|update memory|delete memory)\b",
_last_user.lower(),
):
_filtered_tool_blocks.append(_block)
if _idx < len(converted_calls):
_filtered_converted_calls.append(converted_calls[_idx])
else:
_dropped_memory_lookup = True
if _dropped_memory_lookup:
logger.info(
"[agent-intent] odysseus qwen dropped manage_memory lookup; answering from compact memory"
)
tool_blocks = _filtered_tool_blocks
converted_calls = _filtered_converted_calls
if used_native:
native_tool_calls = _filtered_converted_calls
if not tool_blocks:
_force_answer = True
messages.append({
"role": "system",
"content": (
"Answer the user's identity/personal-memory question from the compact "
"saved memory facts already provided. Do not call manage_memory or any tool."
),
})
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
continue
# Force-answer round: we told the model to STOP calling tools and
# answer. If it ignored that and emitted a (possibly DSML) tool
# call anyway, discard it — don't execute, don't re-loop. Keep
@@ -3358,6 +3763,8 @@ async def stream_agent_loop(
# on reload (#3222 follow-up).
cleaned_round = strip_tool_blocks(round_response, skip_fenced=(_is_api_model and not used_native and not guide_only)).strip()
round_texts.append(cleaned_round)
if _ody_qwen_finetune_model and not tool_blocks and cleaned_round:
yield f'data: {json.dumps({"delta": cleaned_round})}\n\n'
if not tool_blocks:
# ── Completion verifier (mechanism 3a) ────────────────────
@@ -3819,6 +4226,39 @@ async def stream_agent_loop(
tool_output_data["diff"] = result["diff"]
yield f'data: {json.dumps(tool_output_data)}\n\n'
if block.tool_type == "manage_notes":
_notes_action = ""
try:
_notes_args = json.loads(block.content or "{}")
if isinstance(_notes_args, dict):
_notes_action = str(_notes_args.get("action") or "").lower()
except Exception:
_notes_action = ""
_notes_text = ""
if not result.get("error"):
if _notes_action in {"list", "search", "find", "view", "lis"}:
_notes_text = _note_list_summary_from_tool_output(
result.get("output") or result.get("results") or result.get("content") or ""
)
elif _notes_action in {"add", "update", "delete", "toggle_item"}:
_notes_text = str(
result.get("response")
or result.get("output")
or result.get("results")
or ""
).strip()
if _notes_text.startswith("AI: "):
_notes_text = _notes_text[4:].strip()
if _notes_text and not re.match(r"^(done|note|item|deleted)\b", _notes_text, re.IGNORECASE):
_notes_text = f"Done — {_notes_text}"
if _notes_text:
_clean_current = strip_tool_blocks(full_response).strip()
if _notes_text not in _clean_current:
_prefix = "\n\n" if _clean_current else ""
full_response = (_clean_current + _prefix + _notes_text).strip()
yield f'data: {json.dumps({"delta": _prefix + _notes_text})}\n\n'
_ody_notes_tool_completed = True
# This must be the final UI event for ask_user: the frontend appends
# the card below the now-settled tool node and cancels any between-
# round spinner. The turn ends after the current tool batch.
@@ -3863,6 +4303,7 @@ async def stream_agent_loop(
_title = (result.get("note_title") or "").strip()
_label = f"View note: {_title}" if _title else "View note"
_anchor = f"\n\n[{_label}](#note-{_nid})\n"
full_response = (full_response.rstrip() + _anchor).strip()
yield 'data: ' + json.dumps({"delta": _anchor}) + '\n\n'
# Save for history persistence
@@ -3934,6 +4375,10 @@ async def stream_agent_loop(
logger.info("[agent] odysseus doc tool completed after one textual tool block")
break
if _ody_notes_finetune_mode and _ody_notes_tool_completed:
logger.info("[agent] odysseus notes completed from deterministic tool output")
break
# Feed results back to LLM for next round
# Pass the CONVERTED calls (aligned 1:1 with tool_result_texts), not the
# raw native_tool_calls: a call that failed to convert is dropped from
@@ -3974,6 +4419,27 @@ async def stream_agent_loop(
if _fallback_chunk:
yield _fallback_chunk
# Do not persist raw textual tool-call JSON / role markers as assistant
# prose. Local finetunes may emit those before the parser catches and
# executes them; saved history should contain only the user-facing answer.
full_response = strip_tool_blocks(full_response).strip()
if _ody_notes_finetune_mode and tool_events:
for _ev in reversed(tool_events):
if _ev.get("tool") != "manage_notes":
continue
_notes_action = ""
try:
_cmd_args = json.loads(_ev.get("command") or "{}")
if isinstance(_cmd_args, dict):
_notes_action = str(_cmd_args.get("action") or "").lower()
except Exception:
_notes_action = ""
if _notes_action in {"list", "search", "find", "view", "lis"}:
_notes_summary = _note_list_summary_from_tool_output(_ev.get("output") or "")
if _notes_summary:
full_response = _notes_summary
break
# --- Final metrics ---
total_duration = time.time() - total_start
metrics = _compute_final_metrics(
+96 -2
View File
@@ -130,15 +130,70 @@ def _looks_like_email_document(text: str = "", title: str = "") -> bool:
return True
return bool(_re.search(r"(?im)^To:\s*", s) and _re.search(r"(?im)^Subject:\s*", s))
def _split_email_header_body(text: str) -> tuple[str, str]:
if "\n---\n" in (text or ""):
header, body = (text or "").split("\n---\n", 1)
return header.rstrip(), body.strip()
return (text or "").strip(), ""
def _split_email_reply_history(body: str) -> tuple[str, str]:
"""Split draft body from quoted/original email history.
Email reply docs keep the original thread below the user's new reply. Models
often rewrite only the fresh reply body; this helper keeps the historical
block from being wiped when update_document/edit_document replaces content.
"""
text = body or ""
literal = "---------- Previous message ----------"
literal_idx = text.find(literal)
if literal_idx >= 0:
return text[:literal_idx].strip(), text[literal_idx:].strip()
patterns = [
r"(?m)^On .+ wrote:\s*$",
r"(?m)^> .+",
]
starts = []
for pat in patterns:
m = re.search(pat, text)
if m:
starts.append(m.start())
if not starts:
return text.strip(), ""
idx = min(starts)
return text[:idx].strip(), text[idx:].strip()
def _merge_email_headers(old_header: str, new_header: str) -> str:
"""Preserve routing/threading metadata if a model omits it."""
protected = (
"In-Reply-To", "References", "X-Source-UID", "X-Source-Folder",
"X-Attachments", "X-Forward-Attachments",
)
lines = [l for l in (new_header or "").splitlines() if l.strip()]
present = {l.split(":", 1)[0].strip().lower() for l in lines if ":" in l}
for old_line in (old_header or "").splitlines():
if ":" not in old_line:
continue
key = old_line.split(":", 1)[0].strip()
if key in protected and key.lower() not in present:
lines.append(old_line)
present.add(key.lower())
return "\n".join(lines).rstrip()
def _coerce_email_document_content(existing: str, incoming: str) -> str:
"""Keep email docs in the To/Subject/---/body shape even if a model writes
only the body or dumps header labels without the separator."""
import re as _re
old = existing or ""
new = (incoming or "").strip()
old_header, old_body = _split_email_header_body(old)
_, old_history = _split_email_reply_history(old_body)
if "\n---\n" in new:
return new
header = old.split("\n---\n", 1)[0] if "\n---\n" in old else "To: \nSubject: "
new_header, new_body = _split_email_header_body(new)
new_own, new_history = _split_email_reply_history(new_body)
if old_history and not new_history:
new_body = (new_own + "\n\n" + old_history).strip()
return _merge_email_headers(old_header, new_header).rstrip() + "\n---\n" + new_body
header = old_header if old_header else "To: \nSubject: "
if _looks_like_email_document(new):
lines = new.splitlines()
last_header_idx = -1
@@ -152,6 +207,9 @@ def _coerce_email_document_content(existing: str, incoming: str) -> str:
body = "\n".join(body_lines).strip()
else:
body = new
_, incoming_history = _split_email_reply_history(body)
if old_history and not incoming_history:
body = (body.strip() + "\n\n" + old_history).strip()
return header.rstrip() + "\n---\n" + body
def parse_edit_blocks(content: str) -> list:
@@ -463,6 +521,42 @@ class EditDocumentTool:
if not doc:
return {"error": "No documents exist to edit"}
is_email_doc = doc.language == "email" or _looks_like_email_document(doc.current_content or "", doc.title or "")
blank_find_edits = [e for e in edits if not (e.get("find") or "").strip()]
if blank_find_edits:
if is_email_doc:
replacement_body = (blank_find_edits[0].get("replace") or "").strip()
if not replacement_body:
return {"error": "No edits applied — blank FIND block had no replacement text"}
updated_content = _coerce_email_document_content(doc.current_content or "", replacement_body)
applied = 1
skipped = max(0, len(edits) - 1)
doc.language = "email"
new_ver = doc.version_count + 1
ver = DocumentVersion(
id=str(uuid.uuid4()),
document_id=target_id,
version_number=new_ver,
content=updated_content,
summary=f"Edited email body by {_active_model or 'AI'}",
source="ai",
)
doc.current_content = updated_content
doc.version_count = new_ver
db.add(ver)
db.commit()
return {
"action": "edit",
"doc_id": target_id,
"title": doc.title,
"language": doc.language,
"content": updated_content,
"version": new_ver,
"applied": applied,
"skipped": skipped,
}
return {"error": "No edits applied — FIND text cannot be blank"}
updated_content = doc.current_content
applied = 0
skipped = 0
+14 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import inspect
import json
from typing import Dict, Any
@@ -109,8 +110,20 @@ class WebFetchTool:
url = "https://" + url
loop = asyncio.get_running_loop()
try:
def _fetch():
kwargs = {"timeout": 10}
try:
sig = inspect.signature(fetch_webpage_content)
if "max_bytes" in sig.parameters:
kwargs["max_bytes"] = max_bytes
except (TypeError, ValueError):
# Some deployed/test shims may not expose a signature.
# Prefer compatibility over failing the whole fetch.
pass
return fetch_webpage_content(url, **kwargs)
result = await asyncio.wait_for(
loop.run_in_executor(None, lambda: fetch_webpage_content(url, timeout=10, max_bytes=max_bytes)),
loop.run_in_executor(None, _fetch),
timeout=30,
)
except asyncio.TimeoutError:
+36 -1
View File
@@ -47,6 +47,33 @@ def _endpoint_cached_models(ep) -> list:
return models if isinstance(models, list) else []
def _endpoint_pinned_models(ep) -> list:
raw = getattr(ep, "pinned_models", None)
if not raw:
return []
try:
models = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
return []
return models if isinstance(models, list) else []
def _is_mlx_deepseek_v4_repo_id(model_id: str) -> bool:
return "mlx-community/deepseek-v4" in str(model_id or "").lower()
def _is_mlx_deepseek_v4_shim_id(model_id: str) -> bool:
return "/.cache/odysseus/mlx-shims/deepseek-v4" in str(model_id or "").lower()
def _filter_mlx_deepseek_v4_repo_when_shimmed(model_ids) -> list:
ids = list(model_ids or [])
has_shim = any(_is_mlx_deepseek_v4_shim_id(m) for m in ids)
if not has_shim:
return ids
return [m for m in ids if not _is_mlx_deepseek_v4_repo_id(m)]
def _endpoint_hidden_models(ep) -> set:
"""Model ids the admin disabled on this endpoint (the UI's hidden list)."""
raw = getattr(ep, "hidden_models", None)
@@ -67,7 +94,15 @@ def _endpoint_enabled_models(ep) -> list:
raw first one resolves to a model that 400s ("requires terms acceptance").
"""
hidden = _endpoint_hidden_models(ep)
return [m for m in _endpoint_cached_models(ep) if m not in hidden]
merged = []
seen = set()
for m in [*_endpoint_cached_models(ep), *_endpoint_pinned_models(ep)]:
if not isinstance(m, str) or not m or m in seen:
continue
seen.add(m)
merged.append(m)
merged = _filter_mlx_deepseek_v4_repo_when_shimmed(merged)
return [m for m in merged if m not in hidden]
def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]:
+17 -7
View File
@@ -17,6 +17,7 @@ _ACTIVE_REQUESTS = 0
_LAST_ACTIVITY = 0.0
_LAST_BROWSER_ACTIVITY = 0.0
_COND: asyncio.Condition | None = None
_COND_LOOP: asyncio.AbstractEventLoop | None = None
def _enabled() -> bool:
@@ -47,14 +48,20 @@ def _browser_active_seconds() -> float:
def _condition() -> asyncio.Condition:
global _COND
if _COND is None:
global _COND, _COND_LOOP
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if _COND is None or _COND_LOOP is not loop:
_COND = asyncio.Condition()
_COND_LOOP = loop
return _COND
_PASSIVE_EXACT_PATHS = {
"/api/activity/heartbeat",
"/api/client-perf",
"/api/tasks/notifications",
"/api/research/active",
"/api/email/urgency-state",
@@ -100,15 +107,18 @@ def _has_recent_browser_activity(now: float | None = None) -> bool:
def has_foreground_activity(now: float | None = None) -> bool:
"""Return True when foreground browser/model work should stop background jobs.
This is intentionally narrower than `wait_for_interactive_quiet`: active
request tracking is good for delaying task startup, but a running task
should not cancel itself just because the UI polls a passive endpoint.
Browser heartbeats and active chat streams are the durable "user is here"
signals.
Passive polling endpoints are excluded by should_track_interactive_request,
so active/recent request tracking is safe to use here. This matters during
initial page load: the heartbeat may not have landed yet, but the user is
already waiting on real UI requests.
"""
if not _enabled():
return False
t = now if now is not None else time.monotonic()
if _ACTIVE_REQUESTS > 0:
return True
if _LAST_ACTIVITY > 0 and (t - _LAST_ACTIVITY) < _quiet_seconds():
return True
return _has_recent_browser_activity(t) or _has_active_chat_stream()
+248 -5
View File
@@ -8,13 +8,88 @@ import hashlib
import threading
import re
import os
from contextlib import asynccontextmanager
from fastapi import HTTPException
from typing import Optional, Dict, List, Tuple
from src.model_context import get_context_length, DEFAULT_CONTEXT
from src.model_context import get_context_length, DEFAULT_CONTEXT, is_local_endpoint
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_LOCAL_MODEL_LOCK = asyncio.Lock()
_LOCAL_MODEL_WAITING_FOREGROUND = 0
_LOCAL_MODEL_CURRENT: Dict[str, object] = {}
def _local_model_gate_enabled() -> bool:
return os.getenv("ODYSSEUS_LOCAL_MODEL_GATE", "true").lower() not in {"0", "false", "no", "off"}
def _gate_workload(workload: Optional[str]) -> str:
return "background" if str(workload or "").lower() == "background" else "foreground"
@asynccontextmanager
async def _local_model_slot(target_url: str, model: str, workload: Optional[str] = None):
"""Serialize local model traffic, with foreground chat taking priority.
Most local servers expose one GPU/CPU generation pipe even when their HTTP
API accepts multiple requests. Letting scheduled email/tasks and foreground
chat hit that pipe together creates the user-visible "streams crossed" and
"prompt waited behind a task" failure mode. Cloud providers are left alone.
"""
if not _local_model_gate_enabled() or not is_local_endpoint(target_url):
yield
return
global _LOCAL_MODEL_WAITING_FOREGROUND
kind = _gate_workload(workload)
current_task = asyncio.current_task()
if kind == "foreground":
_LOCAL_MODEL_WAITING_FOREGROUND += 1
current = dict(_LOCAL_MODEL_CURRENT)
if current.get("workload") == "background":
task = current.get("task")
if isinstance(task, asyncio.Task) and not task.done():
logger.info(
"[model-gate] cancelling background local model call for foreground request model=%s",
model,
)
task.cancel()
else:
# Background work should not jump in while the browser/chat is active
# or while a foreground request is waiting to acquire the local model.
try:
from src.interactive_gate import has_foreground_activity
except Exception:
has_foreground_activity = lambda: False # type: ignore
while _LOCAL_MODEL_WAITING_FOREGROUND > 0 or has_foreground_activity():
await asyncio.sleep(0.25)
acquired = False
try:
await _LOCAL_MODEL_LOCK.acquire()
acquired = True
if kind == "foreground":
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
_LOCAL_MODEL_CURRENT.clear()
_LOCAL_MODEL_CURRENT.update({
"task": current_task,
"workload": kind,
"url": target_url,
"model": model,
"started": time.time(),
})
yield
finally:
if kind == "foreground":
_LOCAL_MODEL_WAITING_FOREGROUND = max(0, _LOCAL_MODEL_WAITING_FOREGROUND - 1)
if acquired and _LOCAL_MODEL_LOCK.locked():
owner = _LOCAL_MODEL_CURRENT.get("task")
if owner is current_task:
_LOCAL_MODEL_CURRENT.clear()
_LOCAL_MODEL_LOCK.release()
class LLMConfig:
"""Configuration constants for LLM operations."""
DEFAULT_TIMEOUT = 30
@@ -199,6 +274,75 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
payload["thinking"] = True
return f"data: {json.dumps(payload)}\n\n"
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
class _DegenerateStreamGuard:
"""Detect local-model token collapse before it floods the UI.
Some self-hosted models fail by repeating one token forever ("Var Var Var",
"Summer Summer ..."). This is not a useful response and can burn context,
browser memory, and GPU time. Keep the guard conservative: only fire on long
same-token runs or a very dominant repeated token in the recent window.
"""
def __init__(self, model: str):
self.model = model or "model"
self.last_token = ""
self.same_run = 0
self.recent_tokens: List[str] = []
self.total_chars = 0
def check(self, text: str) -> Optional[str]:
if not text:
return None
self.total_chars += len(text)
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text) if len(t) >= 2]
if not tokens:
return None
for token in tokens:
if token == self.last_token:
self.same_run += 1
else:
self.last_token = token
self.same_run = 1
self.recent_tokens.append(token)
if len(self.recent_tokens) > 96:
self.recent_tokens = self.recent_tokens[-96:]
reason = None
if self.same_run >= 28 and self.total_chars >= 100:
reason = f"repeated '{self.last_token}' {self.same_run} times"
elif len(self.recent_tokens) >= 72:
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
count = self.recent_tokens.count(top)
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
if not reason and len(self.recent_tokens) >= 80:
# Phrase loops are common on some local quantized MLX/MoE models:
# "Also be a software developer mode?" repeated forever will not
# trip the single-token guard above, but it is still a wedged
# generation. Require many repeats of the same 4-gram so normal
# prose/list formatting is not interrupted.
grams = [tuple(self.recent_tokens[i:i + 4]) for i in range(0, len(self.recent_tokens) - 3)]
if grams:
top_gram = max(set(grams), key=grams.count)
gram_count = grams.count(top_gram)
if gram_count >= 10:
reason = f"repeated phrase '{' '.join(top_gram)}' {gram_count} times"
if not reason:
return None
logger.warning("[degenerate-stream] aborting model=%s reason=%s", self.model, reason)
message = (
f"Stopped generation: {self.model} started repeating tokens "
f"({reason}). Try a different model or lower temperature."
)
return f'event: error\ndata: {json.dumps({"status": 502, "text": message, "error": message})}\n\n'
def _model_activity_key(url: str, model: str) -> str:
return f"{(url or '').strip()}|{(model or '').strip()}"
@@ -755,6 +899,52 @@ def _apply_local_cache_affinity(payload: Dict, url: str, session_id: Optional[st
payload.setdefault("cache_prompt", True)
def _is_local_minimax_mlx_request(url: str, model: str) -> bool:
"""Local MLX MiniMax-family endpoints need conservative sampling defaults.
The OpenAI-compatible MLX server accepts repetition/frequency penalties.
Some large quantized MiniMax/MoE ports otherwise fall into visible reasoning
loops ("Also be...", "No.", etc.) even for trivial prompts.
"""
if not model:
return False
m = model.lower()
if "minimax" not in m and "mini-max" not in m:
return False
try:
from src.model_context import is_local_endpoint
return is_local_endpoint(url)
except Exception:
return False
def _apply_local_generation_stability(payload: Dict, url: str, model: str) -> None:
if not _is_local_minimax_mlx_request(url, model):
return
if "temperature" in payload:
try:
# MiniMax MLX quantized ports are very sensitive to chat/agent
# harness size. Character presets can ask for a warmer voice, but
# local MiniMax needs a final compatibility clamp or trivial
# prompts can fall into visible reasoning/repetition loops.
payload["temperature"] = min(float(payload.get("temperature") or 0.2), 0.2)
except (TypeError, ValueError):
payload["temperature"] = 0.2
payload.setdefault("top_p", 0.9)
payload.setdefault("top_k", 20)
payload.setdefault("repetition_penalty", 1.12)
payload.setdefault("repetition_context_size", 256)
payload.setdefault("frequency_penalty", 0.08)
payload.setdefault("frequency_context_size", 256)
payload.setdefault("presence_penalty", 0.02)
payload.setdefault("presence_context_size", 256)
payload.setdefault("stop", ["<|im_end|>", "<|endoftext|>", "</s>"])
# A max_tokens of 0 means "server default/unbounded" for many local
# endpoints. Keep simple chats from running forever when the model loops.
if not payload.get("max_tokens") and not payload.get("max_completion_tokens"):
payload["max_tokens"] = 2048
def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str, str]:
h = {"Content-Type": "application/json"}
if isinstance(headers, dict):
@@ -1602,6 +1792,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload[tok_key] = max_tokens
_apply_local_generation_stability(payload, target_url, model)
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
try:
@@ -1711,6 +1902,7 @@ async def llm_call_async(
max_retries: int = LLMConfig.MAX_RETRIES,
prompt_type: Optional[str] = None,
session_id: Optional[str] = None,
workload: str = "foreground",
) -> str:
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
provider = _detect_provider(url)
@@ -1748,6 +1940,7 @@ async def llm_call_async(
max_tokens=max_tokens,
headers=headers,
timeout=timeout,
workload=workload,
):
event_is_error = False
for line in str(chunk).splitlines():
@@ -1813,6 +2006,7 @@ async def llm_call_async(
if provider == "mistral" and _supports_thinking(model):
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
_apply_local_cache_affinity(payload, url, session_id)
_apply_local_generation_stability(payload, target_url, model)
if _is_host_dead(target_url):
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
@@ -1823,9 +2017,10 @@ async def llm_call_async(
attempt += 1
start = time.time()
try:
note_model_activity(target_url, model)
client = _get_http_client()
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
async with _local_model_slot(target_url, model, workload):
note_model_activity(target_url, model)
client = _get_http_client()
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
duration = time.time() - start
if not r.is_success:
friendly = _format_upstream_error(r.status_code, r.text, target_url)
@@ -1867,11 +2062,45 @@ async def llm_call_async(
raise HTTPException(502, f"POST {target_url} failed after {max_retries} attempts: {e}")
await asyncio.sleep(LLMConfig.RETRY_DELAY)
def _stream_target_url(url: str) -> str:
provider = _detect_provider(url)
if provider == "anthropic":
return _normalize_anthropic_url(url)
if provider == "ollama":
return _normalize_ollama_url(url)
if provider == "chatgpt-subscription":
return _normalize_chatgpt_subscription_url(url)
return _normalize_openai_chat_url(url)
async def stream_llm(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
tool_choice_none: bool = False):
tool_choice_none: bool = False, workload: str = "foreground"):
target_url = _stream_target_url(url)
async with _local_model_slot(target_url, model, workload):
async for chunk in _stream_llm_inner(
url,
model,
messages,
temperature=temperature,
max_tokens=max_tokens,
headers=headers,
timeout=timeout,
prompt_type=prompt_type,
tools=tools,
session_id=session_id,
tool_choice_none=tool_choice_none,
):
yield chunk
async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
tool_choice_none: bool = False):
"""Stream LLM responses with improved error handling.
Yields SSE chunks:
@@ -1945,6 +2174,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
payload["think"] = False
_apply_local_cache_affinity(payload, url, session_id)
_apply_local_generation_stability(payload, target_url, model)
h = _provider_headers(provider, headers)
if provider == "copilot":
from src.copilot import apply_request_headers
@@ -1961,6 +2191,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
return
note_model_activity(target_url, model)
degenerate_guard = _DegenerateStreamGuard(model)
# ── ChatGPT Subscription / Codex Responses streaming ──
if provider == "chatgpt-subscription":
@@ -1995,6 +2226,10 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
if evt == "response.output_text.delta":
delta = data.get("delta") or ""
if delta:
_degenerate = degenerate_guard.check(delta)
if _degenerate:
yield _degenerate
return
yield f'data: {json.dumps({"delta": delta})}\n\n'
elif evt == "response.completed":
usage = (data.get("response") or {}).get("usage") or data.get("usage") or {}
@@ -2327,11 +2562,19 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
content = text_part
if reasoning:
_degenerate = degenerate_guard.check(reasoning)
if _degenerate:
yield _degenerate
return
yield _stream_delta_event(reasoning, thinking=True)
if content:
content = _strip_visible_chat_template_artifacts(content)
if not content:
continue
_degenerate = degenerate_guard.check(content)
if _degenerate:
yield _degenerate
return
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
stripped = content.lstrip()
+1
View File
@@ -74,4 +74,5 @@ async def task_llm_call_async(
if not candidates:
raise RuntimeError("No LLM endpoint available for background task")
await wait_for_interactive_quiet("background task LLM")
kwargs.setdefault("workload", "background")
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
+25 -2
View File
@@ -894,10 +894,10 @@ class TaskScheduler:
# Give the just-finished quiet gate a tiny grace window,
# then keep enforcing "background means background" while
# a long email/LLM action is already running.
await asyncio.sleep(1.0)
await asyncio.sleep(0.1)
from src.interactive_gate import has_foreground_activity
while True:
await asyncio.sleep(1.0)
await asyncio.sleep(0.25)
if has_foreground_activity():
foreground_cancel["hit"] = True
logger.info("Task '%s' interrupted because Odysseus became active", task.name)
@@ -1913,6 +1913,7 @@ class TaskScheduler:
disabled_tools=disabled_tools,
relevant_tools=relevant_tools,
fallbacks=_task_fallbacks,
workload="background",
):
if event_str.startswith("data: ") and not event_str.startswith("data: [DONE]"):
try:
@@ -2239,6 +2240,28 @@ class TaskScheduler:
stopped = self._mark_run_aborted(task_id) or stopped
return stopped
async def stop_background_tasks_for_foreground(self, *, reason: str = "Odysseus became active") -> int:
"""Cancel all in-process scheduler tasks because the user is active.
This is intentionally blunt for scheduled/background work: when the
user opens or uses Odysseus, foreground interaction wins immediately.
Manual force-runs can be restarted by the user; automatic jobs will be
deferred by their cancellation path instead of stealing the app.
"""
async with self._executing_lock:
task_ids = list(self._executing)
stopped = 0
for task_id in task_ids:
handle = self._task_handles.get(task_id)
if handle and not handle.done():
handle.cancel()
stopped += 1
if self._mark_run_aborted(task_id):
stopped += 1
if stopped:
logger.info("Stopped %d background scheduler task(s): %s", stopped, reason)
return stopped
async def ensure_defaults(self, owner: str):
"""Create default housekeeping tasks for this owner (idempotent per action)."""
from core.database import SessionLocal, ScheduledTask
+4 -2
View File
@@ -405,8 +405,10 @@ class ToolIndex:
{"chat_with_model", "ask_teacher", "list_models"},
# Deep research intent (incl. common typo "reserach")
frozenset({"web search", "search the web", "search online", "look up",
"google", "latest", "current", "news", "weather",
"forecast", "stock price", "price of"}):
"find info online", "find information online",
"find info", "find information", "online about",
"on the internet", "google", "latest", "current", "news",
"weather", "forecast", "stock price", "price of"}):
{"web_search", "web_fetch"},
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
"deep dive", "deep research", "find out about", "study up on",
+258
View File
@@ -172,6 +172,27 @@ _GEMMA_TOOL_CALL_RE = re.compile(
re.IGNORECASE,
)
# Pattern 4c: Open-function wrapper emitted by some local MLX/Exo models.
# Example:
# <function_model>
# <function_call>web_search</function_call>
# <parameters>{"query":"Sweden news today"}</parameters>
# </function_model>
_FUNCTION_MODEL_OPEN_RE = re.compile(r"<function_model>\s*", re.IGNORECASE)
_FUNCTION_MODEL_CLOSE_RE = re.compile(r"</function_model>", re.IGNORECASE)
_FUNCTION_MODEL_NAME_RE = re.compile(
r"<function_call>\s*([A-Za-z_][\w-]*)\s*</function_call>",
re.IGNORECASE,
)
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
_QWEN_BARE_MARKER_RE = re.compile(
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
re.IGNORECASE,
)
# Pattern 5: DeepSeek DSML markup leaking into content. When deepseek
# models can't emit structured tool_calls (e.g. we sent no tool schemas
@@ -566,6 +587,205 @@ def _parse_raw_web_json_lookup(text: str) -> Optional[tuple[ToolBlock, tuple[int
return block, (start, start + end)
return None
def _looks_like_openai_tool_call_blob(value) -> bool:
"""Return True for raw OpenAI-style tool-call JSON leaked as text."""
if isinstance(value, list):
return bool(value) and all(_looks_like_openai_tool_call_blob(item) for item in value)
if not isinstance(value, dict):
return False
fn = value.get("function")
if isinstance(fn, dict) and isinstance(fn.get("name"), str):
return True
return False
def _raw_openai_tool_call_to_block(value) -> Optional[ToolBlock]:
if isinstance(value, list):
for item in value:
block = _raw_openai_tool_call_to_block(item)
if block:
return block
return None
if not isinstance(value, dict):
return None
fn = value.get("function")
if not isinstance(fn, dict):
return None
name = str(fn.get("name") or "").strip()
if not name:
return None
tool_type = _TOOL_NAME_MAP.get(name, name)
raw_args = fn.get("arguments") or {}
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except (json.JSONDecodeError, TypeError):
args = {}
if not isinstance(args, dict):
args = {}
# Common local-model typo seen in raw OpenAI JSON leaks.
if "text" not in args and "tex" in args:
args["text"] = args.get("tex")
if tool_type.startswith("mcp__"):
return ToolBlock(tool_type, json.dumps(args) if args else "{}")
if name in BUILTIN_EMAIL_TOOLS:
return ToolBlock(f"mcp__email__{name}", json.dumps(args) if args else "{}")
if tool_type not in TOOL_TAGS:
return None
if tool_type == "bash":
content = args.get("command", "")
elif tool_type == "python":
content = args.get("code", "")
elif tool_type == "web_search":
content = args.get("query", "")
queries = args.get("queries")
if not content and isinstance(queries, list) and queries:
content = str(queries[0])
elif not content and queries:
content = str(queries)
tf = args.get("time_filter")
if content and isinstance(tf, str) and tf in ("day", "week", "month", "year"):
content = json.dumps({"query": content, "time_filter": tf})
elif tool_type == "web_fetch":
content = args.get("url") or args.get("domain") or ""
elif tool_type == "read_file":
content = json.dumps(args) if (args.get("offset") or args.get("limit")) else args.get("path", "")
elif tool_type in ("grep", "glob", "ls", "edit_file"):
content = json.dumps(args) if args else "{}"
elif tool_type == "write_file":
content = args.get("path", "") + "\n" + args.get("content", "")
elif tool_type == "create_document":
parts = [args.get("title", "Untitled")]
if args.get("language"):
parts.append(args["language"])
parts.append(args.get("content", ""))
content = "\n".join(parts)
elif tool_type == "update_document":
content = args.get("content", "")
elif tool_type in ("edit_document", "suggest_document"):
marker = "SUGGEST" if tool_type == "suggest_document" else "REPLACE"
blocks = []
for edit in args.get("suggestions" if tool_type == "suggest_document" else "edits", []) or []:
if not isinstance(edit, dict):
continue
block = f'<<<FIND>>>\n{edit.get("find", "")}\n<<<{marker}>>>\n{edit.get("replace", "")}'
if tool_type == "suggest_document":
block += f'\n<<<REASON>>>\n{edit.get("reason", "")}'
blocks.append(block + "\n<<<END>>>")
content = "\n".join(blocks)
elif tool_type == "search_chats":
content = args.get("query", "")
elif tool_type == "chat_with_model":
content = args.get("model", "") + "\n" + args.get("message", "")
elif tool_type == "create_session":
content = args.get("name", "Untitled") + "\n" + args.get("model", "")
elif tool_type == "list_sessions":
content = args.get("filter", "")
elif tool_type == "send_to_session":
content = args.get("session_id", "") + "\n" + args.get("message", "")
elif tool_type == "pipeline":
content = json.dumps({"steps": args.get("steps", [])})
elif tool_type == "manage_session":
action = args.get("action", "")
if action == "list":
keyword = args.get("keyword", "") or args.get("value", "")
content = "list" + (("\n" + keyword) if keyword and keyword.lower() != "current" else "")
else:
content = action + "\n" + args.get("session_id", "current")
if args.get("value"):
content += "\n" + args["value"]
elif tool_type == "manage_memory":
action = args.get("action", "")
if action == "add":
content = "add\n" + str(args.get("text", ""))
if args.get("category"):
content += "\n" + str(args["category"])
elif action == "edit":
content = "edit\n" + str(args.get("memory_id", "")) + "\n" + str(args.get("text", ""))
elif action == "delete":
content = "delete\n" + str(args.get("memory_id", ""))
elif action == "search":
content = "search\n" + str(args.get("text", ""))
elif action == "list":
content = "list" + (("\n" + str(args["category"])) if args.get("category") else "")
else:
content = action
elif tool_type == "ui_control":
action = args.get("action", "")
name_arg = args.get("name", "")
value = args.get("value", "")
if action == "open_panel":
content = f"open_panel {name_arg or value}"
elif action == "toggle":
content = f"toggle {name_arg} {value}"
else:
content = action
elif tool_type in ("manage_tasks", "manage_skills", "api_call", "manage_endpoints",
"manage_mcp", "manage_webhooks", "manage_tokens",
"manage_documents", "manage_settings", "manage_notes",
"manage_research", "manage_bg_jobs"):
content = json.dumps(args)
elif tool_type in ("get_workspace", "list_models"):
content = args.get("filter", "") if tool_type == "list_models" else ""
else:
content = json.dumps(args) if args else ""
return ToolBlock(tool_type, str(content or ""))
def _parse_raw_openai_tool_call_json(text: str) -> Optional[ToolBlock]:
if not isinstance(text, str) or '"function"' not in text:
return None
decoder = json.JSONDecoder()
for match in re.finditer(r"[\[{]", text):
try:
parsed, _end = decoder.raw_decode(text[match.start():])
except json.JSONDecodeError:
continue
block = _raw_openai_tool_call_to_block(parsed)
if block:
return block
return None
def _strip_raw_openai_tool_call_json(text: str) -> str:
"""Strip raw JSON tool calls such as {"function": {...}, "type": "function"}.
Some local models emit native tool-call JSON into assistant text. The agent
can still parse/execute it through the native path, but the raw payload must
not render or persist as prose.
"""
if not isinstance(text, str) or '"function"' not in text:
return text
decoder = json.JSONDecoder()
pieces = []
pos = 0
changed = False
for match in re.finditer(r"[\[{]", text):
start = match.start()
if start < pos:
continue
try:
parsed, rel_end = decoder.raw_decode(text[start:])
except json.JSONDecodeError:
continue
end = start + rel_end
if not _looks_like_openai_tool_call_blob(parsed):
continue
pieces.append(text[pos:start])
pos = end
changed = True
# Common broken local-model suffix: a standalone ] before a role marker.
while pos < len(text) and text[pos] in " \t\r\n":
pos += 1
if pos < len(text) and text[pos] == "]":
pos += 1
if not changed:
return text
pieces.append(text[pos:])
return "".join(pieces)
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
"""Parse a [TOOL_CALL] block into a ToolBlock.
@@ -885,6 +1105,24 @@ def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
return function_call_to_tool_block(tool_name, json.dumps(params))
def _parse_function_model_call(body: str) -> Optional[ToolBlock]:
"""Parse <function_model><function_call>tool</...><parameters>...</...>."""
name_match = _FUNCTION_MODEL_NAME_RE.search(body or "")
if not name_match:
return None
tool_name = name_match.group(1).strip().lower().replace("-", "_")
params = "{}"
for _ms, inner_start, inner_end, _me in _iter_delimited(
body,
_FUNCTION_MODEL_PARAMS_OPEN_RE,
_FUNCTION_MODEL_PARAMS_CLOSE_RE,
):
params = body[inner_start:inner_end].strip() or "{}"
break
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block(tool_name, params)
def _iter_delimited(text, open_re, close_re):
"""Yield ``(match_start, inner_start, inner_end, match_end)`` for each
non-overlapping ``open_re ... close_re`` pair, scanning strictly forward.
@@ -1136,6 +1374,22 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
if block:
blocks.append(block)
# Pattern 4c: <function_model> wrapper from local MLX/Exo models.
if not blocks:
for _ms, inner_start, inner_end, _me in _iter_delimited(
text, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE
):
block = _parse_function_model_call(text[inner_start:inner_end])
if block:
blocks.append(block)
# Pattern 4d: raw OpenAI-style tool-call JSON leaked as assistant text.
# Example: {"function":{"arguments":"{\"action\":\"add\"}","name":"manage_memory"},"type":"function"}
if not blocks:
block = _parse_raw_openai_tool_call_json(text)
if block:
blocks.append(block)
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
if not blocks and not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(text)
@@ -1182,6 +1436,10 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
cleaned = _strip_raw_openai_tool_call_json(cleaned)
cleaned = _QWEN_ROLE_MARKER_RE.sub('', cleaned)
cleaned = _QWEN_BARE_MARKER_RE.sub(' ', cleaned)
if not skip_fenced:
raw_web_json = _parse_raw_web_json_lookup(cleaned)
if raw_web_json:
+28 -8
View File
@@ -18,6 +18,15 @@ from src.tool_security import BUILTIN_EMAIL_TOOLS
logger = logging.getLogger(__name__)
_REQUIRED_NATIVE_TOOL_ARGS = {
"web_search": ("query", "queries"),
"web_fetch": ("url",),
"read_file": ("path",),
"write_file": ("path",),
"edit_file": ("path",),
}
# ---------------------------------------------------------------------------
# OpenAI-compatible function tool schemas
# ---------------------------------------------------------------------------
@@ -187,7 +196,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "create_document",
"description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines) AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large code blocks directly in chat — use this tool instead.",
"description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, make, or generate code, scripts, programs, games, apps, or any long-form or structured content that is more than a short paragraph, AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large generated content directly in chat — use this tool instead.",
"parameters": {
"type": "object",
"properties": {
@@ -576,9 +585,10 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "object",
"properties": {
"action": {"type": "string",
"enum": ["list", "view", "add", "update", "delete", "toggle_item"],
"enum": ["list", "search", "view", "add", "update", "delete", "toggle_item"],
"description": "The action to perform"},
"id": {"type": "string", "description": "Note id (for update/delete/toggle_item); 8-char prefix is fine"},
"query": {"type": "string", "description": "Search text for action='search'"},
"title": {"type": "string", "description": "Note title (for add/update)"},
"content": {"type": "string", "description": "Freeform body text. Use this for note_type='note'. Do NOT use this for checklists — pass `checklist_items` instead."},
"note_type": {"type": "string", "enum": ["note", "checklist"],
@@ -1025,7 +1035,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "manage_contact",
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. Add does not require email: name + phone or name + address is valid. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
"parameters": {
"type": "object",
"properties": {
@@ -1033,9 +1043,9 @@ FUNCTION_TOOL_SCHEMAS = [
"description": "list = show all contacts (with uids); add = create; update = edit by uid; delete = remove by uid."},
"uid": {"type": "string", "description": "Contact UID (required for update/delete; get it from action=list)."},
"name": {"type": "string", "description": "Contact's display name (for add/update)."},
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update)."},
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (for update; first is primary)."},
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers (for update)."},
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update). Optional when phone or address is provided."},
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (first is primary)."},
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers. Valid for add/update."},
"address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."},
},
"required": ["action"]
@@ -1308,6 +1318,11 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
logger.warning(f"Non-object function call arguments for {name}: {args!r}; treating as empty")
args = {}
required_args = _REQUIRED_NATIVE_TOOL_ARGS.get(tool_type)
if required_args and not any(str(args.get(key) or "").strip() for key in required_args):
logger.warning(f"Rejecting empty required arguments for function call {name}: {args!r}")
return None
# Allow MCP tools through (namespaced as mcp__serverid__toolname)
if tool_type.startswith("mcp__"):
content = json.dumps(args) if args else "{}"
@@ -1415,15 +1430,20 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
elif tool_type == "manage_memory":
action = args.get("action", "")
if action == "add":
content = "add\n" + args.get("text", "")
text = args.get("text") or args.get("value") or args.get("content") or ""
if not text and args.get("key"):
text = str(args.get("key") or "")
content = "add\n" + str(text)
if args.get("category"):
content += "\n" + args["category"]
elif args.get("key"):
content += "\n" + str(args["key"])
elif action == "edit":
content = "edit\n" + args.get("memory_id", "") + "\n" + args.get("text", "")
elif action == "delete":
content = "delete\n" + args.get("memory_id", "")
elif action == "search":
content = "search\n" + args.get("text", "")
content = "search\n" + (args.get("text") or args.get("tex") or args.get("query") or "")
elif action == "list":
content = "list"
if args.get("category"):
+23 -10
View File
@@ -108,16 +108,28 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
if action == "add":
email = (args.get("email") or "").strip()
if not email:
return {"error": "email is required for add", "exit_code": 1}
name = (args.get("name") or "").strip() or email.split("@")[0]
# Dedupe by email (same as the /add route).
phones = [str(p or "").strip() for p in (args.get("phones") or []) if str(p or "").strip()]
phone = (args.get("phone") or "").strip()
if phone and phone not in phones:
phones.insert(0, phone)
address = (args.get("address") or "").strip()
name = (args.get("name") or "").strip()
if not name and email:
name = email.split("@")[0]
if not name and not email and not phones and not address:
return {"error": "name plus email, phone, or address is required for add", "exit_code": 1}
if not name:
name = email.split("@")[0] if email else (phones[0] if phones else "Contact")
# Dedupe by email or phone (same as the /add route).
existing = await asyncio.to_thread(cc._fetch_contacts)
for c in existing:
if email.lower() in [e.lower() for e in c.get("emails", [])]:
if email and email.lower() in [e.lower() for e in c.get("emails", [])]:
return {"output": f"{email} is already a contact ({c.get('name','')}).", "exit_code": 0}
ok = await asyncio.to_thread(cc._create_contact, name, email)
return {"output": f"{'Added' if ok else 'Failed to add'} {name} <{email}>.", "exit_code": 0 if ok else 1}
if phones and any(p in (c.get("phones") or []) for p in phones):
return {"output": f"{phones[0]} is already a contact ({c.get('name','')}).", "exit_code": 0}
ok = await asyncio.to_thread(cc._create_contact, name, email, address, phones)
detail = email or ", ".join(phones) or address
return {"output": f"{'Added' if ok else 'Failed to add'} {name} ({detail}).", "exit_code": 0 if ok else 1}
if action in ("update", "edit"):
uid = (args.get("uid") or "").strip()
@@ -129,11 +141,12 @@ async def do_manage_contact(content: str, owner: Optional[str] = None) -> Dict:
emails = [args["email"]]
emails = [e.strip() for e in (emails or []) if e and e.strip()]
phones = [p.strip() for p in (args.get("phones") or []) if p and p.strip()]
if not name and not emails:
return {"error": "Provide a name or emails to update", "exit_code": 1}
address = (args.get("address") or "").strip()
if not name and not emails and not phones and not address:
return {"error": "Provide a name, emails, phones, or address to update", "exit_code": 1}
if not name and emails:
name = emails[0].split("@")[0]
ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones)
ok = await asyncio.to_thread(cc._update_contact, uid, name, emails, phones, address)
return {"output": "Contact updated." if ok else "Update failed.", "exit_code": 0 if ok else 1}
if action == "delete":
+3 -2
View File
@@ -315,6 +315,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", ["mlx_lm.server", "mlx-lm"]),
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
("ComfyUI", ["comfyui/main.py", "/ComfyUI/main.py", "ComfyUI"]),
@@ -590,7 +591,7 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
hint = ""
if isinstance(err_msg, str) and "cmd" in err_msg.lower():
hint = (" — the cmd must START with an allowlisted binary "
"(vllm, python3, llama-server, ollama, sglang, lmdeploy, node, npx). "
"(vllm, python3, llama-server, ollama, sglang, mlx_lm, lmdeploy, node, npx). "
"Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
"env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
"automatically from the host's saved venv settings.")
@@ -635,7 +636,7 @@ async def do_list_served_models(content: str, owner: Optional[str] = None) -> Di
if not merged:
return {
"output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
"output": "No model servers currently running (cookbook task tracker empty; /proc scan found no vLLM / sglang / MLX / llama.cpp / Ollama / ComfyUI / A1111 / Fooocus / InvokeAI / TGI / Aphrodite / Triton / Diffusers processes).",
"exit_code": 0,
}
+76 -25
View File
@@ -27,7 +27,8 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
# Action aliases — match what models actually emit. `create` is the most
# common alternative to `add`. Hyphenated forms also accepted.
action = (args.get("action") or "").replace("-", "_").strip().lower()
raw_action = (args.get("action") or "").replace("-", "_").strip().lower()
action = raw_action
_NOTE_ACTION_ALIASES = {
"create": "add",
"new": "add",
@@ -60,37 +61,68 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
q = q.filter(Note.owner == owner)
return q.first()
def _format_note_list(notes) -> str:
lines = []
for n in notes:
pin = " [PINNED]" if n.pinned else ""
typ = " [checklist]" if n.note_type == "checklist" else ""
lbl = f" #{n.label}" if n.label else ""
title = n.title or "(untitled)"
lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}")
if n.note_type == "checklist" and n.items:
try:
items = json.loads(n.items)
for i, item in enumerate(items):
mark = "x" if item.get("done") else " "
lines.append(f" [{mark}] {i}: {item.get('text', '')}")
except (json.JSONDecodeError, TypeError):
pass
elif n.content:
snippet = n.content[:80].replace("\n", " ")
lines.append(f" {snippet}")
return "\n".join(lines)
try:
if action == "list":
if action in ("list", "search", "find"):
q = db.query(Note)
if owner is not None:
q = q.filter(Note.owner == owner)
if args.get("label"):
q = q.filter(Note.label == args["label"])
label_filter = str(args.get("label") or "").strip()
if label_filter and label_filter.lower() != "default":
q = q.filter(Note.label == label_filter)
show_archived = args.get("archived", False)
q = q.filter(Note.archived == show_archived)
notes = q.order_by(Note.pinned.desc(), Note.updated_at.desc()).all()
if action in ("search", "find"):
query = str(
args.get("query")
or args.get("text")
or args.get("title")
or args.get("content")
or ""
).strip().lower()
if query:
filtered = []
for n in notes:
haystack = " ".join(
str(part or "")
for part in (n.title, n.content, n.label, n.items)
).lower()
if query in haystack:
filtered.append(n)
notes = filtered
if not notes:
return {"response": "No notes found.", "exit_code": 0}
lines = []
for n in notes:
pin = " [PINNED]" if n.pinned else ""
typ = " [checklist]" if n.note_type == "checklist" else ""
lbl = f" #{n.label}" if n.label else ""
title = n.title or "(untitled)"
lines.append(f"- [{n.id[:8]}] **{title}**{pin}{typ}{lbl}")
if n.note_type == "checklist" and n.items:
try:
items = json.loads(n.items)
for i, item in enumerate(items):
mark = "x" if item.get("done") else " "
lines.append(f" [{mark}] {i}: {item.get('text', '')}")
except (json.JSONDecodeError, TypeError):
pass
elif n.content:
snippet = n.content[:80].replace("\n", " ")
lines.append(f" {snippet}")
return {"results": "\n".join(lines)}
return {"results": _format_note_list(notes), "exit_code": 0}
elif action == "view":
note_id = args.get("id", "")
note = _note_by_prefix(note_id)
if not note:
return {"error": f"Note '{note_id}' not found", "exit_code": 1}
if not _note_visible_to_owner(note, owner):
return {"error": "Note not found", "exit_code": 1}
return {"results": _format_note_list([note]), "exit_code": 0}
elif action == "add":
# Accept the various field names models emit: `text` is the most
@@ -120,6 +152,25 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
# `new Date()` resolves the right absolute moment regardless of
# where the user is.
due_raw = args.get("due_date")
if not due_raw:
combined_text = " ".join(
str(v or "")
for v in (title, content_raw, text_raw)
).strip()
lower_combined = combined_text.lower()
looks_like_reminder = (
raw_action in {"remind", "reminder"}
or re.search(r"\bremind(?:er)?\b", lower_combined)
)
if looks_like_reminder:
temporal = re.search(
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
lower_combined,
)
if temporal:
due_raw = temporal.group(0)
due_iso = None
if due_raw:
try:
@@ -170,7 +221,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
# link with no target, leaving the user with a click that
# did nothing and uncertainty about whether the note was made.
return {
"response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
"response": f"{'Reminder' if due_iso else 'Note'} created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
"note_id": note.id,
"note_title": title or "",
"open_url": f"/#open=notes&note={note.id}",
@@ -246,7 +297,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
return {"response": f"Item '{items[index].get('text', '')}' marked {mark}", "exit_code": 0}
else:
return {"error": f"Unknown action: {action}. Use list/add/update/delete/toggle_item", "exit_code": 1}
return {"error": f"Unknown action: {action}. Use list/search/view/add/update/delete/toggle_item", "exit_code": 1}
except Exception as e:
logger.error(f"manage_notes error: {e}")
return {"error": str(e), "exit_code": 1}
+2 -2
View File
@@ -34,8 +34,8 @@ async def do_search_chats(query: str, limit: int = 20, owner: str | None = None)
lines = [f"Found {len(seen_sessions)} session(s) matching \"{query}\":\n"]
for sid, result in seen_sessions.items():
lines.append(f"- **{result.session_name}** (#{sid})")
lines.append(f" Link: [Open chat](#{sid})")
lines.append(f"- [**{result.session_name}**](#session-{sid})")
lines.append(f" Open: [Open chat](#session-{sid})")
lines.append(f" Match ({result.role}): {result.content_snippet}")
if result.context_before:
before = result.context_before[-1]
+7
View File
@@ -538,6 +538,11 @@ _APP_API_BLOCKLIST_METHOD_PATH = (
# sidebar surfaces the session. Raw start works but the agent
# fumbles the payload + the session doesn't reliably show up.
("POST", "/api/research/start"),
# Use web_search — the HTTP search route is UI-shaped and generic
# app_api calls can return empty/poorly formatted results compared with the
# named tool's source-aware output.
("GET", "/api/search"),
("POST", "/api/search"),
# Use the named tools — they handle owner attribution, natural-
# language due_date parsing, timezone, dedup, and tag/category
# normalization. Hitting the raw endpoint via app_api saves a
@@ -653,6 +658,8 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
return {"error": "Don't POST /api/model/serve directly — use the `serve_model` or `serve_preset` tool (handles host resolution, env_prefix, and cookbook tracking).", "exit_code": 1}
if "/api/research/start" in path:
return {"error": "Don't POST /api/research/start directly — use the `trigger_research` tool (it surfaces the session in the Deep Research sidebar).", "exit_code": 1}
if "/api/search" in path:
return {"error": "Don't hit /api/search via app_api — use the `web_search` tool for online lookups, or `web_fetch` for a specific URL.", "exit_code": 1}
if "/api/notes" in path:
return {"error": "Don't hit /api/notes via app_api — use the `manage_notes` tool. It accepts natural-language due_date ('11pm today', 'tomorrow at 9am'), fires reminders from the due_date itself (no separate calendar event), and uses the caller's timezone. The raw endpoint requires ISO-UTC + a separate calendar event, both of which the agent tends to get wrong.", "exit_code": 1}
if "/api/calendar/events" in path:
+256 -75
View File
@@ -53,6 +53,73 @@ window.uiModule = uiModule;
window.adminModule = adminModule;
window.cookbookModule = cookbookModule;
function _isMobileChatInput() {
return window.innerWidth <= 768;
}
function _submitChatFormDirect(form) {
if (!form) return;
if (form.requestSubmit) form.requestSubmit();
else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
function _isForegroundChatBusy() {
const sendBtn = document.querySelector('.send-btn');
return !!window.__odysseusChatBusy
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending')
|| (sendBtn && (sendBtn.title || '').toLowerCase().includes('stop'));
}
function _shouldQueueFromMobileEnter(e, input) {
return e.key === 'Enter'
&& !e.shiftKey
&& !e.ctrlKey
&& !e.metaKey
&& !e.altKey
&& !e.isComposing
&& _isMobileChatInput()
&& _isForegroundChatBusy()
&& !!(input && input.value && input.value.trim());
}
function _shouldQueueFromMobileLineBreak(input) {
return _isMobileChatInput()
&& _isForegroundChatBusy()
&& !!(input && input.value && input.value.trim());
}
function _isLineBreakInputEvent(e) {
return e
&& (e.inputType === 'insertLineBreak'
|| e.inputType === 'insertParagraph'
|| e.data === '\n');
}
function _submitMobileQueuedInput(input) {
if (!input || !_shouldQueueFromMobileLineBreak(input)) return false;
const now = Date.now();
const last = Number(input.dataset.mobileQueueSubmitAt || 0);
if (now - last < 300) return true;
input.dataset.mobileQueueSubmitAt = String(now);
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
return true;
}
window.__odysseusQueueStreamingSubmit = now;
const form = document.getElementById('chat-form');
_submitChatFormDirect(form);
return true;
}
function _syncMobileEnterKeyHint(input) {
if (!input) return;
input.setAttribute('enterkeyhint', (_isMobileChatInput() && _isForegroundChatBusy()) ? 'send' : 'enter');
}
function _countLineBreaks(s) {
return ((s || '').match(/\n/g) || []).length;
}
function initForegroundActivityHeartbeat() {
let lastSent = 0;
const minGapMs = 12000;
@@ -185,6 +252,50 @@ 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
// ============================================
@@ -195,9 +306,9 @@ function initializeEventListeners() {
// File attachments (inside overflow menu)
const _overflowAttach = el('overflow-attach-btn');
if (_overflowAttach) _overflowAttach.addEventListener('click', fileHandlerModule.openPicker);
el('file-input').addEventListener('change', (e)=>{
for (const f of e.target.files) fileHandlerModule.addFiles([f]);
fileHandlerModule.renderAttachStrip();
el('file-input').addEventListener('change', async (e)=>{
await fileHandlerModule.addFiles(Array.from(e.target.files || []));
e.target.value = '';
// Refocus textarea after file picker closes (mobile keyboard)
const ta = el('message');
if (ta) setTimeout(() => ta.focus(), 100);
@@ -211,7 +322,7 @@ function initializeEventListeners() {
if (item.kind === 'file'){
const f = item.getAsFile();
if (f) {
fileHandlerModule.addFiles([f]);
await fileHandlerModule.addFiles([f]);
changed = true;
}
}
@@ -371,8 +482,10 @@ function initializeEventListeners() {
}
const body = child.querySelector('.body');
// Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
// to avoid extra newlines and formatting artifacts.
const text = body ? (body.dataset.raw || body.innerText || body.textContent || '').trim() : '';
// to avoid extra newlines and formatting artifacts. Raw text lives on
// the outer .msg in the main renderer; keep body.dataset.raw as a legacy
// fallback for older/reused render paths.
const text = (child.dataset?.raw || body?.dataset?.raw || body?.innerText || body?.textContent || '').trim();
if (text) parts.push(`${label}: ${text}`);
} else if (child.classList?.contains('agent-thread')) {
const lines = ['[Tool calls]'];
@@ -3113,10 +3226,7 @@ function initializeEventListeners() {
}, { passive: true });
})();
// New session button on icon rail
const railNewSession = el('rail-new-session');
if (railNewSession) {
railNewSession.addEventListener('click', async () => {
async function _handleNewChatAction({ preferModel = true, focus = true } = {}) {
if (!sessionModule) return;
if (_closeCompareIfActive()) return;
_deactivateIncognito();
@@ -3125,18 +3235,24 @@ function initializeEventListeners() {
// Clear research mode if active
const _resChk = el('research-toggle');
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
if (await _createDirectChatFromPreferredModel()) return;
if (preferModel && await _createDirectChatFromPreferredModel()) return;
// No models at all — show welcome screen
sessionModule.setCurrentSessionId(null);
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
_startFreshChat();
const docBtn3 = el('overflow-doc-btn');
if (docBtn3) docBtn3.classList.remove('active', 'has-docs');
const box = el('chat-history');
if (box) box.innerHTML = '';
if (chatModule && chatModule.showWelcomeScreen) {
chatModule.showWelcomeScreen();
}
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
if (focus) {
const input = el('message');
if (input) { try { input.focus(); } catch (_) {} }
}
}
// New session button on icon rail
const railNewSession = el('rail-new-session');
if (railNewSession) {
railNewSession.addEventListener('click', async (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
await _handleNewChatAction();
});
}
@@ -3163,31 +3279,17 @@ function initializeEventListeners() {
// Logo click → new chat (same logic as rail new-session button)
const brandBtn = el('sidebar-brand-btn');
if (brandBtn) {
brandBtn.addEventListener('click', async () => {
if (!sessionModule) return;
if (_closeCompareIfActive()) return;
_deactivateIncognito();
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
// Clear research toggle when starting a fresh chat (not via research button)
_syncResearchIndicator(false);
if (await _createDirectChatFromPreferredModel()) return;
// No models at all — show welcome screen
sessionModule.setCurrentSessionId(null);
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
const docBtn2 = el('overflow-doc-btn');
if (docBtn2) docBtn2.classList.remove('active', 'has-docs');
const box = el('chat-history');
if (box) box.innerHTML = '';
if (chatModule && chatModule.showWelcomeScreen) chatModule.showWelcomeScreen();
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
brandBtn.addEventListener('click', async (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
await _handleNewChatAction();
});
}
const sidebarNewChatBtn = el('sidebar-new-chat-btn');
if (sidebarNewChatBtn) {
sidebarNewChatBtn.addEventListener('click', () => {
const brandBtn = el('sidebar-brand-btn');
if (brandBtn) brandBtn.click();
sidebarNewChatBtn.addEventListener('click', async (e) => {
if (e) { e.preventDefault(); e.stopImmediatePropagation(); }
await _handleNewChatAction();
});
}
@@ -3226,17 +3328,38 @@ function initializeEventListeners() {
// Textarea auto-resize
const textarea = el('message');
if (textarea) {
_syncMobileEnterKeyHint(textarea);
window.addEventListener('odysseus:chat-busy-change', () => _syncMobileEnterKeyHint(textarea));
uiModule.autoResize(textarea);
textarea.addEventListener('input', () => {
let previousTextareaValue = textarea.value || '';
textarea.addEventListener('beforeinput', (e) => {
if (_isLineBreakInputEvent(e) && _shouldQueueFromMobileLineBreak(textarea)) {
e.preventDefault();
e.stopPropagation();
_submitMobileQueuedInput(textarea);
}
});
textarea.addEventListener('input', (e) => {
const currentValue = textarea.value || '';
const insertedLineBreak = _isLineBreakInputEvent(e)
|| _countLineBreaks(currentValue) > _countLineBreaks(previousTextareaValue);
if (insertedLineBreak && _shouldQueueFromMobileLineBreak(textarea)) {
textarea.value = currentValue.replace(/\n+$/g, '');
previousTextareaValue = textarea.value || '';
_submitMobileQueuedInput(textarea);
return;
}
previousTextareaValue = currentValue;
uiModule.autoResize(textarea);
_syncMobileEnterKeyHint(textarea);
});
textarea.addEventListener('paste', () => {
setTimeout(() => uiModule.autoResize(textarea), 1);
});
textarea.addEventListener('keydown', (e) => {
const isMobile = window.innerWidth <= 768
const isMobile = _isMobileChatInput();
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
if (_shouldQueueFromMobileEnter(e, textarea) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) {
// If ghost autocomplete is active, accept the suggestion instead of submitting
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
e.preventDefault();
@@ -3249,8 +3372,13 @@ function initializeEventListeners() {
// Check if already submitting before triggering form submission
const form = el('chat-form');
if (form) {
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.click();
if (_isForegroundChatBusy() && textarea.value && textarea.value.trim()) {
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
return;
}
window.__odysseusQueueStreamingSubmit = Date.now();
}
_submitChatFormDirect(form);
}
}
});
@@ -3429,7 +3557,7 @@ function initializeEventListeners() {
// Now submit the form (the /new command handler will process it)
setTimeout(() => {
const form = el('chat-form');
if (form) form.querySelector('button[type="submit"]').click();
_submitChatFormDirect(form);
}, 0);
}
};
@@ -3685,10 +3813,31 @@ function startOdysseusApp() {
return fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount() > 0;
}
function _updateStreamingSubmitButton() {
if (!sendBtn || sendBtn.dataset.mode !== 'streaming') return false;
const hasText = messageInput && messageInput.value.trim().length > 0;
const nextPhase = hasText ? 'queue' : 'processing';
if (sendBtn.dataset.phase === nextPhase) return true;
sendBtn.dataset.phase = nextPhase;
sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land');
if (hasText) {
sendBtn.innerHTML = _sendIcon;
sendBtn.title = 'Queue message';
} else {
sendBtn.innerHTML = _stopIcon;
sendBtn.title = 'Stop generation';
}
return true;
}
function _updateSendBtnIcon() {
if (!sendBtn) return;
// Don't override if streaming (stop button) or recording
if (sendBtn.dataset.mode === 'streaming' || sendBtn.dataset.mode === 'recording') return;
if (sendBtn.dataset.mode === 'streaming') {
_updateStreamingSubmitButton();
return;
}
// Don't override if recording
if (sendBtn.dataset.mode === 'recording') return;
const prevMode = sendBtn.dataset.mode || '';
const hasText = messageInput && messageInput.value.trim().length > 0;
const hasFiles = _hasAttachments();
@@ -3784,6 +3933,12 @@ function startOdysseusApp() {
const hasText = messageInput && messageInput.value.trim().length > 0;
const hasFiles = _hasAttachments();
if (sendBtn.dataset.mode === 'streaming') {
if (hasText) window.__odysseusQueueStreamingSubmit = Date.now();
handleSubmit(e);
return;
}
// New chat mode — empty input, no attachments, no STT
if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
if (sessionModule) {
@@ -3823,9 +3978,10 @@ function startOdysseusApp() {
// Enter to send (shift+enter for newline), or new chat when empty
if (messageInput) {
messageInput.addEventListener('keydown', (e) => {
const isMobile = window.innerWidth <= 768
if (e.defaultPrevented) return;
const isMobile = _isMobileChatInput();
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
if (_shouldQueueFromMobileEnter(e, messageInput) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) {
e.preventDefault();
// Flush the debounced icon update so dataset.mode reflects the current
// text state. Without this, a fast type-and-Enter would still see the
@@ -3836,7 +3992,13 @@ function startOdysseusApp() {
if (railNew) railNew.click();
return;
}
handleSubmit(e);
if (_isForegroundChatBusy() && messageInput.value && messageInput.value.trim()) {
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
return;
}
window.__odysseusQueueStreamingSubmit = Date.now();
}
_submitChatFormDirect(document.getElementById('chat-form'));
}
});
}
@@ -3855,7 +4017,11 @@ function startOdysseusApp() {
_syncModelPickerAutohide();
messageInput.addEventListener('input', () => {
_syncModelPickerAutohide();
_debouncedUpdateIcon();
if (sendBtn && sendBtn.dataset.mode === 'streaming') {
_updateSendBtnIcon();
} else {
_debouncedUpdateIcon();
}
}, { passive: true });
}
@@ -3910,14 +4076,13 @@ function startOdysseusApp() {
_showDropHighlight();
});
chatContainer.addEventListener('drop', (e) => {
chatContainer.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
_hideDropHighlight();
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
fileHandlerModule.addFiles(files);
fileHandlerModule.renderAttachStrip();
await fileHandlerModule.addFiles(files);
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
});
@@ -3934,12 +4099,13 @@ function startOdysseusApp() {
attachStrip.style.borderRadius = '4px';
});
attachStrip.addEventListener('drop', (e) => {
attachStrip.addEventListener('drop', async (e) => {
e.preventDefault();
attachStrip.style.backgroundColor = '';
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
await fileHandlerModule.addFiles(files);
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
@@ -4007,14 +4173,13 @@ function startOdysseusApp() {
if (_compareActive() && !e.relatedTarget) _hideCmpShield();
}, true);
window.addEventListener('dragend', _hideCmpShield, true);
window.addEventListener('drop', (e) => {
window.addEventListener('drop', async (e) => {
if (!_isFileDrag(e) || !_compareActive()) return;
e.preventDefault();
_hideCmpShield();
const files = Array.from(e.dataTransfer.files || []);
if (!files.length) return;
fileHandlerModule.addFiles(files);
fileHandlerModule.renderAttachStrip();
await fileHandlerModule.addFiles(files);
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
}, true);
@@ -4049,24 +4214,40 @@ function startOdysseusApp() {
console.error('Session module not loaded!');
}
// Non-critical: load in parallel, resolve silently
modelsModule.refreshModels(false).then(() => {
try { sessionModule.updateModelPicker(); } catch (_) {}
const modelsBox = document.getElementById('models');
const hasModels = modelsBox && modelsBox.querySelector('.models-row');
if (!hasModels) {
const tip = document.getElementById('welcome-tip');
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
}
}).catch(() => {});
modelsModule.refreshProviders();
ragModule.loadPersonalDocs();
memoryModule.loadMemories(); // Ensure memories are loaded on page load
// Ensure the memory list is rendered after loading
setTimeout(async () => {
await memoryModule.loadMemories();
}, 1000);
const runNonCriticalStartup = (fn, delay = 4000) => {
let tries = 0;
const run = () => {
const busy = !!window.__odysseusChatBusy
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending');
if (busy && tries < 12) {
tries += 1;
setTimeout(run, 2500);
return;
}
try { fn(); } catch (e) { console.warn('non-critical startup task failed:', e); }
};
setTimeout(() => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(run, { timeout: 5000 });
} else {
run();
}
}, delay);
};
// 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);
runNonCriticalStartup(() => ragModule.loadPersonalDocs(), 9000);
runNonCriticalStartup(() => memoryModule.loadMemories(), 12000);
// Ensure proper initial state
voiceRecorderModule.init();
+5 -33
View File
@@ -1356,7 +1356,7 @@
<div class="settings-sidebar">
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
<button class="settings-nav-item active" data-settings-tab="services">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
<span>Add Models</span>
</button>
<button class="settings-nav-item" data-settings-tab="added-models">
@@ -1979,34 +1979,6 @@
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>Translation</h2>
<div class="settings-row" style="align-items:center;">
<div style="flex:1;min-width:0;">
<div class="settings-label" style="margin-bottom:3px;">Auto translate</div>
<div class="admin-toggle-sub" style="margin:0;">When an opened email appears to be in another language, prepare a translated view.</div>
</div>
<label class="admin-switch"><input type="checkbox" id="set-email-auto-translate"><span class="admin-slider"></span></label>
</div>
<div class="settings-row" style="align-items:center;margin-top:8px;">
<label class="settings-label" for="set-email-translate-language">Translate to</label>
<input id="set-email-translate-language" class="settings-select" list="set-email-translate-language-list" placeholder="English, Swedish, Japanese..." style="max-width:220px;">
<datalist id="set-email-translate-language-list">
<option value="English"></option>
<option value="Swedish"></option>
<option value="Norwegian"></option>
<option value="Danish"></option>
<option value="Japanese"></option>
<option value="Spanish"></option>
<option value="French"></option>
<option value="German"></option>
<option value="British English"></option>
<option value="Plain English"></option>
</datalist>
<span id="set-email-translate-msg" style="font-size:11px;margin-left:auto;"></span>
</div>
</div>
</div>
<!-- ═══ REMINDERS TAB ═══ -->
@@ -2122,7 +2094,7 @@
<!-- ── Local card ─────────────────────────────────────────── -->
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;"><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="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
@@ -2154,7 +2126,7 @@
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1;min-width:0;border-top-left-radius:0;border-bottom-left-radius:0;">
</div>
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add
</button>
</div>
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
@@ -2167,7 +2139,7 @@
<!-- ── API card ───────────────────────────────────────────── -->
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;"><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"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="11" cy="12" r="8"/><path d="M19 5v6"/><path d="M16 8h6"/><path d="M7 12h8"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
@@ -2236,7 +2208,7 @@
<input id="adm-epApiKey" type="password" placeholder="API key, e.g. sk-proj-AbCdEf…" autocomplete="off" style="flex:1;padding-left:28px;height:32px;box-sizing:border-box;">
</div>
<button class="admin-btn-add" id="adm-epAddBtn" style="height:32px;min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;box-sizing:border-box;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add
</button>
</div>
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
+1 -1
View File
@@ -471,7 +471,7 @@ async function loadEndpoints() {
const listLegacy = el('adm-epList');
// Refresh model picker so new endpoints show up in chat
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels();
window.modelsModule.refreshModels(true);
setTimeout(() => {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
+279 -46
View File
@@ -48,9 +48,53 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
try {
window.__odysseusChatBusy = !!active;
window.__odysseusChatBusyUntil = active ? Date.now() + 120000 : Date.now() + 1200;
window.dispatchEvent(new CustomEvent('odysseus:chat-busy-change', { detail: { active: !!active } }));
} catch (_) {}
}
let _pendingContinue = null; // Stores the stopped AI element to merge with new response
function _createChatSendPerf() {
const started = (performance && performance.now) ? performance.now() : Date.now();
let last = started;
let reported = false;
const stages = [];
const now = () => (performance && performance.now) ? performance.now() : Date.now();
return {
mark(name) {
const t = now();
stages.push({ name, delta_ms: Math.round(t - last), at_ms: Math.round(t - started) });
last = t;
},
report(extra) {
if (reported) return;
const total = Math.round(now() - started);
const slowStage = stages.some(s => (s.delta_ms || 0) >= 1500);
if (total < 1500 && !slowStage) return;
reported = true;
const payload = JSON.stringify({
type: 'chat_send',
total_ms: total,
stages,
extra: extra || '',
session: sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : '',
});
try {
if (navigator.sendBeacon) {
navigator.sendBeacon(`${API_BASE}/api/client-perf`, new Blob([payload], { type: 'application/json' }));
return;
}
} catch (_) {}
try {
fetch(`${API_BASE}/api/client-perf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
credentials: 'same-origin',
}).catch(() => {});
} catch (_) {}
}
};
}
// ── Auto-recovery: when a turn's stream silently dies (connection drop) or
// goes quiet while the connection is alive, re-engage the model with a
// completion handshake instead of leaving it hung. Capped so it can't loop.
@@ -120,6 +164,26 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
return final && !visible ? 'Done.' : visible;
}
function _stripIncompleteRawToolJsonForChat(text) {
const s = String(text || '');
const starts = ['[{"function"', '[\n{"function"', '{"function"'];
let idx = -1;
for (const marker of starts) idx = Math.max(idx, s.lastIndexOf(marker));
if (idx < 0) return s;
const tail = s.slice(idx);
// Complete raw OpenAI-style function blobs are removed by stripToolBlocks.
// While the stream is still mid-JSON, hide the tail so it never flashes in
// the chat bubble as prose.
if (!/"type"\s*:\s*"function"/.test(tail) || !/\}\s*\]?\s*(?:<\/?\|(?:assistant|assistan|user|system|tool)\|>?)?\s*$/i.test(tail)) {
return s.slice(0, idx);
}
return s;
}
function _streamDisplayText(text, opts = {}) {
return stripToolBlocks(_stripIncompleteRawToolJsonForChat(_stripDocumentFenceForChat(text, opts)));
}
function _showDocumentWritingStatus(contentEl) {
const msg = contentEl && contentEl.closest ? contentEl.closest('.msg') : null;
const chatBox = document.getElementById('chat-history');
@@ -204,6 +268,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let _streamSessionId = null; // Session ID for the currently active reader loop
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
let _webLockRelease = null; // Function to release the Web Lock held during streaming
let _staleStreamProbeInFlight = false;
const STALE_LOCAL_STREAM_MS = 15000;
/** Check if an SSE reader is still actively connected for a session. */
function hasActiveStream(sessionId) {
@@ -326,7 +392,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// arrow out for the stop icon — otherwise the swap happens mid-flight
// and the user sees nothing fly out.
setTimeout(() => {
submitBtn.innerHTML = _stopSvg;
if (submitBtn.dataset.mode !== 'streaming') return;
const msgInput = uiModule.el('message');
const hasQueuedText = !!(msgInput && msgInput.value && msgInput.value.trim());
submitBtn.innerHTML = hasQueuedText && icons ? icons.send : _stopSvg;
submitBtn.dataset.phase = hasQueuedText ? 'queue' : 'processing';
submitBtn.title = hasQueuedText ? 'Queue message' : 'Stop generation';
submitBtn.classList.remove('anim-launch');
void submitBtn.offsetWidth;
submitBtn.classList.add('anim-land');
@@ -471,6 +542,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
return true;
}
export function queueStreamingComposerRequest() {
if (!isStreaming) return false;
const queuedInput = uiModule.el('message');
const queuedText = (queuedInput && queuedInput.value || '').trim();
if (!queuedText) return false;
if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) {
try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {}
return true;
}
if (_queueAgentRequest(queuedText)) {
queuedInput.value = '';
queuedInput.dispatchEvent(new Event('input', { bubbles: true }));
if (uiModule.autoResize) uiModule.autoResize(queuedInput);
try { window._updateSendBtnIcon && window._updateSendBtnIcon(); } catch (_) {}
}
return true;
}
function _drainQueuedAgentRequests() {
if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return;
if (_queuedDrainTimer) return;
@@ -507,21 +596,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
return;
}
// If currently streaming, a non-empty composer means "queue this next".
// Empty composer keeps the existing Stop behavior.
// If currently streaming, keyboard Enter can queue a non-empty composer.
// Clicking the stop icon should still stop normally, even if text exists.
if (isStreaming) {
const queuedInput = uiModule.el('message');
const queuedText = (queuedInput && queuedInput.value || '').trim();
if (queuedText) {
if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) {
try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {}
return;
}
if (_queueAgentRequest(queuedText)) {
queuedInput.value = '';
queuedInput.dispatchEvent(new Event('input', { bubbles: true }));
if (uiModule.autoResize) uiModule.autoResize(queuedInput);
}
const queueRequestedAt = Number(window.__odysseusQueueStreamingSubmit || 0);
const shouldQueueStreamingSubmit = queueRequestedAt && Date.now() - queueRequestedAt < 1200;
window.__odysseusQueueStreamingSubmit = 0;
if (shouldQueueStreamingSubmit && queueStreamingComposerRequest()) {
return;
}
if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) {
@@ -652,6 +733,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// --- Send-path entry: block re-clicks between submit and stream start ---
if (_sendInFlight) return;
const _sendPerf = _createChatSendPerf();
_sendInFlight = true;
_setForegroundChatBusy(true);
// Instant visual feedback so the user sees their click was accepted
@@ -710,18 +792,34 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Materialize pending session (deferred from model click) on first message
if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
_sendPerf.mark('pending_session_begin');
const ok = await sessionModule.materializePendingSession();
_sendPerf.mark('pending_session_done');
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
}
if (!sessionModule.getCurrentSessionId()) {
// Auto-create a session using default chat config. Always fetch fresh
// so that a recent Settings change takes effect without a page reload.
try {
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
if (pending && pending.url && pending.modelId) {
const ok = await sessionModule.materializePendingSession();
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
}
} catch (_) {}
}
if (!sessionModule.getCurrentSessionId()) {
// Auto-create a session using default chat config. Always fetch fresh
// so that a recent Settings change takes effect without a page reload.
try {
let dc = null;
try {
_sendPerf.mark('default_chat_fetch_begin');
const dcRes = await fetch('/api/default-chat');
dc = await dcRes.json();
_sendPerf.mark('default_chat_fetch_done');
if (dc && dc.endpoint_url && dc.model) {
try { window.__odysseusDefaultChat = dc; } catch (_) {}
}
@@ -729,8 +827,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
dc = (typeof window !== 'undefined' && window.__odysseusDefaultChat) || null;
}
if (dc.endpoint_url && dc.model) {
_sendPerf.mark('direct_chat_create_begin');
await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
_sendPerf.mark('direct_chat_create_done');
const ok = await sessionModule.materializePendingSession();
_sendPerf.mark('direct_chat_materialize_done');
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
} else {
el('message').value = '';
@@ -886,6 +987,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!skipBubble) {
_userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
}
_sendPerf.mark('user_bubble_visible');
messageInput.value = '';
messageInput.style.height = '';
messageInput.dispatchEvent(new Event('input'));
@@ -921,9 +1023,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let ids = [];
try {
_sendPerf.mark('upload_begin');
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
_sendPerf.mark('upload_done');
} catch(e) {
console.error('upload failed', e);
_sendPerf.mark('upload_failed');
}
if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
@@ -1021,11 +1126,18 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let activeDocIdForSend = documentModule && typeof documentModule.getCurrentDocId === 'function'
? documentModule.getCurrentDocId()
: null;
if (!activeDocIdForSend && activeEmailComposerCtx?.docId) {
if (activeEmailComposerCtx?.docId) {
activeDocIdForSend = activeEmailComposerCtx.docId;
}
if (documentModule && activeDocIdForSend) {
try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); }
try {
_sendPerf.mark('doc_save_begin');
await documentModule.saveDocument();
_sendPerf.mark('doc_save_done');
} catch(e) {
console.warn('doc auto-save failed', e);
_sendPerf.mark('doc_save_failed');
}
}
// Inject document selection context if present
@@ -1057,7 +1169,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (ids.length) fd.append('attachments', JSON.stringify(ids));
// Auto-save & send active doc ID so the backend sees latest content
if (documentModule && activeDocIdForSend) {
try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
try {
_sendPerf.mark('doc_silent_save_begin');
await documentModule.saveDocument({ silent: true });
_sendPerf.mark('doc_silent_save_done');
} catch (_e) {
_sendPerf.mark('doc_silent_save_failed');
}
fd.append('active_doc_id', activeDocIdForSend);
}
// Active email context — when an email reader is open, pass its
@@ -1076,7 +1194,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (emCtx.account) fd.append('active_email_account', String(emCtx.account));
}
} catch (_e) { /* best-effort */ }
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
// Web toggle: pre-search in Chat mode only. Agent mode should not
// opportunistically hit SearXNG just because the chat search toggle is
// on; explicit web/current-info requests are handled by the backend
// intent gate.
const toggleState = Storage.loadToggleState();
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
const incognitoChk = el('incognito-toggle');
@@ -1088,13 +1209,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
fd.append('mode', isAgentMode ? 'agent' : 'chat');
if (el('web-toggle').checked) {
if (isAgentMode) {
fd.append('allow_web_search', 'true');
} else {
if (!isAgentMode) {
fd.append('use_web', 'true');
}
} else if (isAgentMode) {
fd.append('allow_web_search', 'false');
}
if (isAgentMode) {
fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false');
}
if (el('research-toggle').checked) {
fd.append('use_research', 'true');
@@ -1229,12 +1349,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; }
catch { return ''; }
})();
_sendPerf.mark('chat_stream_post_begin');
const res = await fetch(`${API_BASE}/api/chat_stream`, {
method: 'POST',
body: fd,
headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName },
signal: abortCtrl.signal
});
_sendPerf.mark('chat_stream_headers');
_sendPerf.report('headers_received');
if (!res.ok) {
clearResponseTimeout();
@@ -1280,6 +1403,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
const reader = res.body.getReader();
_sendPerf.mark('reader_ready');
_sendPerf.report('reader_ready');
const decoder = new TextDecoder();
let buffer = '';
let metrics = null;
@@ -1322,6 +1447,32 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
return contentDiv;
}
function _ensureVisibleRoundForDelta() {
if (!roundHolder || roundHolder.style.display !== 'none') return;
const box = document.getElementById('chat-history');
if (!box) {
roundHolder.style.display = '';
return;
}
const newWrap = document.createElement('div');
newWrap.className = 'msg msg-ai msg-continuation streaming';
const newRole = document.createElement('div');
newRole.className = 'role';
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
const requested = holder?._requestedModel || metaS?.model || modelName;
const actual = holder?._actualModel || requested;
newRole.textContent = _modelRouteLabel(requested, actual) || '';
_applyModelColor(newRole, actual);
newWrap.appendChild(newRole);
const newBody = document.createElement('div');
newBody.className = 'body';
newWrap.appendChild(newBody);
box.appendChild(newWrap);
if (lastToolThread && lastToolThread.isConnected) lastToolThread.classList.add('has-bottom');
roundHolder = newWrap;
roundText = '';
roundFinalized = false;
}
const esc = uiModule.esc;
// Remove thinking spinner helper
_removeThinkingSpinner = () => {
@@ -1445,7 +1596,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Direct render helper for streaming text
_renderStream = () => {
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
let dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
const bodyEl = roundHolder.querySelector('.body');
const contentEl = _ensureStreamLayout(bodyEl);
@@ -1700,24 +1851,25 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; }
} else if (_thinkOpen) {
_delta = '</think>' + _delta; _thinkOpen = false;
}
const wasEmpty = !accumulated;
accumulated += _delta;
roundText += _delta;
currentAccumulated = accumulated; // Update global tracker
// First token arrived — switch stop button from processing to streaming
if (wasEmpty && submitBtn && !_isBg) {
submitBtn.dataset.phase = 'receiving';
}
const wasEmpty = !accumulated;
accumulated += _delta;
currentAccumulated = accumulated; // Update global tracker
// First token arrived — switch stop button from processing to streaming
if (wasEmpty && submitBtn && !_isBg) {
submitBtn.dataset.phase = 'receiving';
}
// Update background map if running in background
if (_isBg) {
var bgEntry = _backgroundStreams.get(streamSessionId);
if (bgEntry) bgEntry.accumulated = accumulated;
continue; // Skip all DOM writes
}
if (bgEntry) bgEntry.accumulated = accumulated;
continue; // Skip all DOM writes
}
_ensureVisibleRoundForDelta();
roundText += _delta;
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n');
const fenceIdx = roundText.indexOf(fenceMarker);
@@ -1852,7 +2004,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} else if (hasUnclosedThink && isThinking) {
if (_liveThinkInner) {
// Extract raw thinking text (strip known thinking wrappers and prefixes)
var thinkText = markdownModule.normalizeThinkingMarkup(roundText)
var thinkText = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText))
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
.replace(/<\|channel>thought\s*\n?/gi, '')
.replace(/<\|channel>response\s*\n?/gi, '')
@@ -2285,6 +2437,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!_isBg) {
uiModule.showToast('Context compacted — older messages summarized');
}
} else if (json.type === 'context_trimmed') {
if (!_isBg) {
const d = json.data || {};
const before = Number(d.messages_before || 0);
const after = Number(d.messages_after || 0);
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
uiModule.showToast(`Context trimmed for this model${detail}`);
}
} else if (json.type === 'metrics') {
metrics = json.data;
if (!_isBg && holder && metrics) {
@@ -2331,7 +2491,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!roundFinalized) {
roundFinalized = true;
if (spinner && spinner.element) spinner.destroy();
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
if (dt.trim()) {
var _body3 = roundHolder.querySelector('.body');
var _contentEl3 = _ensureStreamLayout(_body3);
@@ -2555,6 +2715,25 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (json.ui_event) {
chatStream.handleUIControl(json);
}
// Native document tool calls can arrive as a completed
// tool_output without the text-fence streaming path. Open the
// document editor from the real doc metadata carried on the
// tool result so "create a document" never leaves only a chat
// link behind if the later doc_update event is missed.
if (
documentModule
&& json.doc_id
&& ['create_document', 'update_document', 'edit_document'].includes(json.tool)
) {
documentModule.handleDocUpdate({
type: 'doc_update',
doc_id: json.doc_id,
title: json.document_title || '',
language: json.document_language || '',
version: json.document_version || 1,
content: json.document_content || '',
});
}
// Schedule a thinking spinner between tool rounds (short delay so
// agent_step in the same SSE chunk can cancel it before it shows)
@@ -2807,7 +2986,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
// Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
const finalDisplay = stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: _docFenceOpened }));
const finalDisplay = _streamDisplayText(roundText, { final: _docFenceOpened });
if (finalDisplay.trim()) {
var _body4 = roundHolder.querySelector('.body');
// Preserve sources expanded state before final render
@@ -2873,11 +3052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
} else if (roundHolder !== holder) {
// Check if there's thinking content worth showing
const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
const _thinkingOnly = markdownModule.extractThinkingBlocks(_streamDisplayText(roundText));
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
// Show thinking in a collapsed section even if no visible reply text
const _body4c = roundHolder.querySelector('.body');
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(_streamDisplayText(roundText));
} else {
roundHolder.style.display = 'none';
// Thread above expected a bubble below — remove has-bottom since bubble is hidden
@@ -3092,6 +3271,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
return;
}
if (abortReason === 'stale-local') {
const staleMsg = 'Stream connection ended. Composer unlocked; send again if needed.';
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`<div style="opacity:0.7;font-style:italic;padding:4px 0;">[${staleMsg}]</div>`;
} else if (holder && accumulated) {
const staleNote = document.createElement('div');
staleNote.className = 'stopped-indicator';
staleNote.innerHTML = `<span style="opacity:0.7;">[${staleMsg}]</span>`;
holder.querySelector('.body').appendChild(staleNote);
}
currentAbort = null;
return;
}
// User-initiated stop (or browser navigation abort).
// Stopped before any text arrived — keep the bubble as a
// "Cancelled by user" record (so it survives a refresh).
@@ -3398,12 +3592,51 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
box.appendChild(bar);
if (uiModule.scrollHistory) uiModule.scrollHistory();
}
async function _probeStaleLocalStream() {
if (!isStreaming || _staleStreamProbeInFlight) return;
if (Date.now() - _lastReaderActivity < STALE_LOCAL_STREAM_MS) return;
const sid = _streamSessionId || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId());
if (!sid) return;
if (_backgroundStreams.has(sid) || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() !== sid)) return;
_staleStreamProbeInFlight = true;
try {
const res = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, {
credentials: 'same-origin',
cache: 'no-store',
});
if (!isStreaming || _backgroundStreams.has(sid)) return;
if (res.status !== 404) return;
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
if (currentAbort && !currentAbort.signal.aborted) {
currentAbort._reason = 'stale-local';
currentAbort.abort();
}
isStreaming = false;
_setForegroundChatBusy(false);
_sendInFlight = false;
if (_webLockRelease) {
_webLockRelease();
_webLockRelease = null;
}
const submitBtn = document.querySelector('.send-btn');
if (submitBtn) updateSubmitButton('idle', submitBtn);
const messageInput = uiModule.el('message');
if (messageInput) messageInput.disabled = false;
_drainQueuedAgentRequests();
} catch (err) {
console.warn('[stream-watchdog] Stream status probe failed:', err);
} finally {
_staleStreamProbeInFlight = false;
}
}
function _startStallWatchdog() {
// Disabled: the server-side stall detector / auto-continue (agent
// loop-breaker) handles quiet/stalled streams now, so the manual
// "Quiet for Nm — still working?" banner is redundant (and annoying).
// Keep the old noisy stall banner disabled. This watchdog only unlocks
// a dead local stream after the backend confirms no active stream exists.
if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
_removeStallBanner();
_stallWatchdog = setInterval(_probeStaleLocalStream, 5000);
}
function _stopStallWatchdog() {
if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; }
@@ -3565,7 +3798,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
};
const renderDelta = () => {
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: docFenceOpened })));
const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText, { final: docFenceOpened }));
if (docFenceOpened && !dt.trim()) {
_showDocumentWritingStatus(contentDiv);
} else {
+73 -19
View File
@@ -474,6 +474,10 @@ const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
// (e.g. mid-stream before the closing tag arrives).
const DSML_TOOL_RE = /<\s*[|]+\s*DSML\s*[|]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[|]+\s*DSML\s*[|]+\s*tool_calls\s*>|$)/gi;
const DSML_STRAY_RE = /<\s*\/?\s*[|]+\s*DSML\s*[|]+[^>]*>/gi;
const DSML_INVOKE_RE = /<\s*[|]+\s*DSML\s*[|]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[|]+\s*DSML\s*[|]+\s*invoke\s*>|$)/gi;
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
@@ -911,9 +915,13 @@ export function stripToolBlocks(text) {
let cleaned = text.replace(TOOL_CALL_RE, '');
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, stripExecutedFence);
cleaned = cleaned.replace(DSML_TOOL_RE, '');
cleaned = cleaned.replace(DSML_INVOKE_RE, '');
cleaned = cleaned.replace(DSML_STRAY_RE, '');
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
cleaned = cleaned.replace(XML_INVOKE_RE, '');
cleaned = cleaned.replace(RAW_OPENAI_TOOL_JSON_RE, '');
cleaned = cleaned.replace(QWEN_ROLE_MARKER_RE, '');
cleaned = cleaned.replace(QWEN_BARE_MARKER_RE, ' ');
cleaned = cleaned.replace(TOOL_NARRATION_RE, '');
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
return cleaned.trim();
@@ -1152,17 +1160,41 @@ document.addEventListener('click', function(e) {
while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement;
const a = _t && _t.closest && _t.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href') || '';
const rawHref = a.getAttribute('href') || '';
let href = rawHref;
try {
const parsed = new URL(rawHref, window.location.origin);
if (parsed.origin === window.location.origin && parsed.pathname === window.location.pathname) {
href = parsed.hash || rawHref;
}
} catch (_) {}
if (!href.startsWith('#')) return;
const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
let m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
if (!m) {
const noteOpen = href.match(/^#open=notes&note=([^&]+)/);
if (noteOpen) m = ['note', 'note', decodeURIComponent(noteOpen[1])];
}
if (!m) {
const bareSession = href.match(/^#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (bareSession) m = ['session', 'session', bareSession[1]];
}
if (!m) return;
e.preventDefault();
e.stopPropagation();
const [, kind, id] = m;
if (kind === 'session') {
try {
a.classList.add('is-loading');
a.setAttribute('aria-busy', 'true');
} catch {}
import('./sessions.js').then(mod => {
const fn = mod.selectSession || (mod.default && mod.default.selectSession);
if (fn) fn(id);
if (fn) return fn(id, { showLoading: true, immediateLoading: true });
}).finally(() => {
try {
a.classList.remove('is-loading');
a.removeAttribute('aria-busy');
} catch {}
});
} else if (kind === 'document') {
import('./document.js').then(mod => {
@@ -1175,6 +1207,11 @@ document.addEventListener('click', function(e) {
import('./notes.js').then(mod => {
const open = mod.openNote || (mod.default && mod.default.openNote);
if (open) open(id);
try {
if (/^#(?:note-|open=notes&note=)/.test(window.location.hash || '')) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
} catch (_) {}
}).catch(() => {});
} else if (kind === 'image') {
import('./gallery.js').then(mod => {
@@ -1208,7 +1245,7 @@ document.addEventListener('click', function(e) {
if (open) open(id);
}).catch(() => {});
}
});
}, true);
/**
* Build a generated-image bubble element.
@@ -1326,6 +1363,25 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
});
actions.appendChild(editBtn);
if (imageId) {
const galleryBtn = document.createElement('button');
galleryBtn.className = 'footer-copy-btn footer-open-gallery-btn';
galleryBtn.type = 'button';
galleryBtn.title = 'Open in gallery';
galleryBtn.innerHTML = '<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="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><span>Open in gallery</span>';
galleryBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const mod = await import('./gallery.js');
const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage);
if (open) open(imageId);
} catch (err) {
console.error('[chat] open in gallery failed', err);
}
});
actions.appendChild(galleryBtn);
}
const delBtn = document.createElement('button');
delBtn.className = 'footer-copy-btn footer-delete-btn';
delBtn.type = 'button';
@@ -1789,19 +1845,16 @@ export function displayMetrics(messageElement, metrics) {
}
}
// Default: show tok/s if available, else fall back to other stats
// Keep token counts in the Message Stats popup; the footer should stay slim.
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
const metricsLabel = tps != null && tps !== 'undefined'
const hasTps = tps != null && tps !== 'undefined';
const metricsLabel = hasTps
? `${tps} tok/s`
: costStr0
? `${outputTokens} tok · ${costStr0}`
: outputTokens
? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
: inputTokens
? `${inputTokens} in${responseTime != null ? ' · ' + responseTime + 's' : ''}`
: responseTime != null
? `${responseTime}s`
: '';
? costStr0
: responseTime != null
? `${responseTime}s`
: '';
if (!metricsLabel) return;
metricsContainer.textContent = metricsLabel;
metricsContainer.style.cursor = 'pointer';
@@ -2442,8 +2495,8 @@ export function addMessage(role, content, modelName, metadata) {
.trim();
}
wrap.dataset.raw = text;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
wrap.dataset.raw = text;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
// Prepend sources box if saved in metadata
var sourcesPrefix = '';
var findingsSuffix = '';
@@ -2466,9 +2519,10 @@ export function addMessage(role, content, modelName, metadata) {
'<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>' + metadata.thinking + '</think>\n\n' + text
);
b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix;
} else {
b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
}
} else {
b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
}
b.dataset.raw = text;
// The vision/OCR caption is stripped from the displayed text above (so the
// bubble doesn't show the raw model output) but no longer rendered as an
+10 -2
View File
@@ -185,9 +185,17 @@ export function handleUIControl(uiData) {
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
try {
var existingDocId = documentModule && documentModule.findEmailDocId
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
var activeCtx = documentModule && documentModule.getActiveEmailComposerContext
? documentModule.getActiveEmailComposerContext()
: null;
var sameActiveDraft = activeCtx
&& String(activeCtx.sourceUid || '') === String(uiData.uid || '')
&& String(activeCtx.sourceFolder || 'INBOX') === String(uiData.folder || 'INBOX');
var existingDocId = sameActiveDraft && activeCtx.docId
? activeCtx.docId
: (documentModule && documentModule.findEmailDocId
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
: null);
if (existingDocId && documentModule.replaceEmailReplyBody) {
if (documentModule.loadDocument) documentModule.loadDocument(existingDocId);
documentModule.replaceEmailReplyBody(existingDocId, uiData.body || '', { force: true });
+11 -1
View File
@@ -49,6 +49,16 @@ const _RECIPES = [
},
},
// ── MLX ───────────────────────────────────────────────────────────────
{
backend: 'mlx_lm',
label: 'Any MLX model',
match: () => true,
variants: {
pip: { commands: ['uv pip install -U mlx-lm'] },
},
},
// ── llama.cpp ─────────────────────────────────────────────────────────
{
backend: 'llama_cpp',
@@ -75,7 +85,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', 'llama_cpp']);
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', '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
+229 -10
View File
@@ -149,6 +149,118 @@ function _openCpuServeEdit(panel) {
});
}
function _taskForDiagnosisPanel(panel) {
const taskEl = panel?.closest?.('.cookbook-task');
const taskId = taskEl?.dataset?.taskId || '';
if (!taskId) return null;
return (_loadTasks() || []).find(t => t.sessionId === taskId) || null;
}
function _pythonFromServeCmd(cmd) {
const s = String(cmd || '');
const abs = s.match(/(?:^|\s)(\/[^\s]+\/bin\/python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
if (abs) return abs[1];
const rel = s.match(/(?:^|\s)(python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
return rel ? rel[1] : '';
}
function _pythonForDiagnosisPanel(panel) {
const task = _taskForDiagnosisPanel(panel);
const fromCmd = _pythonFromServeCmd(task?.payload?._cmd || '');
if (fromCmd) return fromCmd;
return (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`
: 'python3';
}
function _sglangKernelRepairCommand(panel) {
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U --force-reinstall --no-cache-dir sglang-kernel`;
}
function _mlxLmInstallCommand(panel) {
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U mlx-lm`;
}
async function _repairSglangKernel(panel) {
const task = _taskForDiagnosisPanel(panel);
uiModule.showToast('Repairing sglang-kernel on the selected server...');
await _launchServeTask(
'repair-sglang-kernel',
'pip-update',
_sglangKernelRepairCommand(panel),
null,
task?.remoteHost || undefined,
task ? {
serverKey: task.remoteServerKey || task.remoteHost || '',
serverName: task.remoteServerName || task.remoteHost || '',
} : null,
);
}
async function _installMlxLm(panel) {
const task = _taskForDiagnosisPanel(panel);
uiModule.showToast('Installing MLX LM on the selected server...');
await _launchServeTask(
'install-mlx-lm',
'pip-update',
_mlxLmInstallCommand(panel),
null,
task?.remoteHost || undefined,
_diagnosisTargetMeta(task),
);
}
function _diagnosisTargetMeta(task) {
return task ? {
serverKey: task.remoteServerKey || task.remoteHost || '',
serverName: task.remoteServerName || task.remoteHost || '',
} : null;
}
function _gpuCleanupCommand() {
return `set -u
echo "[odysseus] Clearing GPU compute processes..."
if command -v nvidia-smi >/dev/null 2>&1; then
pids="$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | tr -d " " | grep -E "^[0-9]+$" | sort -u)"
if [ -z "$pids" ]; then
echo "[odysseus] No NVIDIA compute processes found."
exit 0
fi
echo "[odysseus] GPU PIDs: $pids"
ps -fp $pids 2>/dev/null || true
echo "[odysseus] Sending TERM..."
kill -TERM $pids || true
sleep 3
alive=""
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then alive="$alive $pid"; fi
done
if [ -n "$alive" ]; then
echo "[odysseus] Force killing remaining GPU PIDs:$alive"
kill -KILL $alive || true
fi
sleep 1
remaining="$(nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits 2>/dev/null | sed "/^$/d" || true)"
if [ -n "$remaining" ]; then
echo "[odysseus] GPU processes still remain:"
echo "$remaining"
exit 2
fi
echo "[odysseus] GPU cleanup complete. No NVIDIA compute processes remain."
else
echo "[odysseus] nvidia-smi not found; falling back to common model-server process cleanup."
pkill -TERM -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
sleep 3
pkill -KILL -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
echo "[odysseus] Fallback cleanup complete."
fi`;
}
async function _clearGpuProcesses(panel) {
uiModule.showToast('Clearing GPU compute processes on the selected server...');
await _runQuickCmd(panel, _gpuCleanupCommand());
}
// Infer the gated base repo that single-file checkpoints need configs from
function _inferBaseRepo(text) {
if (!text) return null;
@@ -161,6 +273,25 @@ function _inferBaseRepo(text) {
}
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,
message: 'tmux is missing on this server.',
suggestion: 'Suggested action: open Dependencies and install tmux on the selected server.',
fixes: [
{ label: 'Open tmux dependency', action: () => _openCookbookDependencies('tmux') },
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y tmux') },
{ label: 'Copy pacman install', action: () => _copyText('sudo pacman -S --needed tmux') },
],
},
{
pattern: /Port \d+ is already serving|port is occupied by a different model|choose another port before launching/i,
message: 'Serve port is already occupied by another model.',
suggestion: 'Suggested action: stop the old server or choose a different port before relaunching.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Copy check command', action: () => _copyText('curl http://127.0.0.1:PORT/v1/models') },
],
},
{
pattern: /No available memory for the cache blocks|Available KV cache memory:.*-/i,
message: 'No GPU memory left for KV cache after loading model.',
@@ -179,6 +310,39 @@ export const ERROR_PATTERNS = [
{ label: 'Retry with --max-num-seqs 32', action: (panel) => _serveAutoRetry(panel, '--max-num-seqs 32') },
],
},
{
pattern: /Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static|Raise --mem-fraction-static above/i,
message: 'SGLang static memory fraction is too low for the loaded weights.',
suggestion: 'Suggested action: retry with --mem-fraction-static 0.80 so weights fit and KV cache can still allocate.',
fixes: [
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
{ label: 'Retry mem 0.82', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.82') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /get_paged_mqa_logits_metadata|deepseek_v4_backend\.py|paged_mqa_metadata\.cuh:113.*CUDA error:\s*invalid argument/i,
message: 'SGLang DeepSeek-V4 attention metadata kernel failed on this GPU/runtime.',
suggestion: 'Suggested action: stop retrying graph/memory tweaks for this exact FP8 command. SGLangs RTX PRO 6000 recipe uses the original deepseek-ai/DeepSeek-V4-Flash checkpoint with --moe-runner-backend marlin, not the converted sgl-project FP8 checkpoint. Try that recipe/checkpoint, official SGLang container/nightly, or supported Hopper/Blackwell hardware.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Copy error', action: (panel) => {
const task = panel.closest('.cookbook-task');
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
_copyText(text.trim());
} },
],
},
{
pattern: /Capture cuda graph failed|cuda graph failed|paged_mqa_metadata|cuda-graph-backend-decode|cuda-graph-max-bs-decode|CUDA error:\s*invalid argument/i,
message: 'SGLang failed while capturing decode CUDA graphs.',
suggestion: 'Suggested action: disable SGLang decode CUDA graph for this launch. DeepSeek-V4 is reaching graph capture, but this kernel is failing on the target hardware.',
fixes: [
{ label: 'Disable decode graph', action: (panel) => _serveAutoRetryReplace(panel, '--cuda-graph-backend-decode', 'disabled') },
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory/i,
message: 'GPU ran out of memory. Try more GPUs (higher TP) or lower context.',
@@ -334,6 +498,18 @@ export const ERROR_PATTERNS = [
{ label: 'Enable enforce eager', action: (panel) => _setPanelCheckbox(panel, 'enforce_eager', true) },
],
},
{
pattern: /memory capacity is unbalanced|Some GPUs may be occupied by other processes|pre_model_load_memory=.*local_gpu_memory/i,
message: 'SGLang refused to start because free GPU memory is uneven across the selected tensor-parallel GPUs.',
suggestion: 'Suggested action: run Clear GPUs, then relaunch. If it still fails, choose only equally free GPUs or lower TP/context.',
fixes: [
{ label: 'Clear GPUs', action: (panel) => _clearGpuProcesses(panel) },
{ label: 'Copy clear command', action: () => _copyText(_gpuCleanupCommand()) },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Set TP to 1', action: (panel) => _setPanelField(panel, 'tp', '1') },
{ label: 'Lower context', action: (panel) => _setPanelField(panel, 'ctx', '32768') },
],
},
{
pattern: /KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context/i,
message: 'Context length too large for available GPU memory.',
@@ -355,11 +531,14 @@ export const ERROR_PATTERNS = [
],
},
{
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i,
message: 'SGLang native dependencies are missing on this server.',
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|(?:Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|Could not load any common_ops library|Please ensure sgl_kernel is properly installed/i,
message: 'SGLang native kernel/runtime is missing or mismatched on this server.',
suggestion: 'Suggested action: relaunch with Odysseus venv CUDA library path fix. If the venv does not contain the matching NVIDIA runtime libs, run Repair sglang-kernel.',
fixes: [
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Repair sglang-kernel', action: (panel) => _repairSglangKernel(panel) },
{ label: 'Copy repair command', action: (panel) => _copyText(_sglangKernelRepairCommand(panel)) },
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
{ label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
},
@@ -371,6 +550,30 @@ export const ERROR_PATTERNS = [
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') },
],
},
{
pattern: /No module named ['"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed/i,
message: 'MLX LM is not installed on this server.',
suggestion: 'Suggested action: install mlx-lm in the selected Python environment. MLX serving is intended for Apple Silicon Macs.',
fixes: [
{ label: 'Install MLX LM', action: (panel) => _installMlxLm(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') },
],
},
{
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.',
suggestion: 'Suggested action: relaunch from the cached local snapshot path. Odysseus now rewrites MLX repo-id launches to the newest local Hugging Face snapshot when it exists on the selected Mac.',
fixes: [
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
{ label: 'Copy error', action: (panel) => {
const task = panel.closest('.cookbook-task');
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
_copyText(text.trim());
} },
],
},
{
pattern: /No accelerator \(CUDA, XPU, HPU, NPU, MUSA, MPS\) is available|Triton is not supported on current platform/i,
message: 'SGLang needs a visible GPU/accelerator on this server.',
@@ -834,22 +1037,38 @@ export function _clearDiagnosis(panel) {
// ── Quick command ──
export async function _runQuickCmd(panel, cmd) {
const task = _taskForDiagnosisPanel(panel);
let fullCmd = cmd;
if (_envState.remoteHost) {
fullCmd = _sshCmd(_envState.remoteHost, cmd);
const host = task?.remoteHost || _envState.remoteHost || '';
const port = task?.sshPort || task?.payload?.ssh_port || _envState.sshPort || '';
if (host) {
fullCmd = _sshCmd(host, cmd, port);
}
const diag = panel.querySelector('.cookbook-diagnosis');
if (diag) { diag.classList.remove('hidden'); diag.textContent = `Running: ${fullCmd}...`; }
if (diag) {
diag.classList.remove('hidden');
diag.innerHTML = '<div class="cookbook-diag-message">Running command...</div>';
}
try {
const res = await fetch('/api/shell/stream', {
const res = await fetch('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: fullCmd }),
body: JSON.stringify({ command: fullCmd, timeout: 60 }),
});
if (diag) diag.textContent = res.ok ? `Done: ${cmd}` : `Failed (HTTP ${res.status})`;
const data = await res.json().catch(() => ({}));
const out = [data.stdout, data.stderr].filter(Boolean).join('\n').trim();
const ok = res.ok && Number(data.exit_code ?? 1) === 0;
if (diag) {
diag.innerHTML = ''
+ `<div class="cookbook-diag-message">${ok ? 'Command completed.' : 'Command failed.'}</div>`
+ `<div class="cookbook-diag-suggestion" style="opacity:0.75;margin-top:1px;">Exit code: ${_diagEsc(data.exit_code ?? 'unknown')}</div>`
+ (out ? `<pre class="cookbook-diag-output" style="margin:6px 0 0;white-space:pre-wrap;max-height:180px;overflow:auto;font-size:10px;line-height:1.35;">${_diagEsc(out)}</pre>` : '');
}
} catch (e) {
if (diag) diag.textContent = `Error: ${e.message}`;
if (diag) {
diag.innerHTML = `<div class="cookbook-diag-message">Command error.</div><div class="cookbook-diag-suggestion">${_diagEsc(e.message)}</div>`;
}
}
}
+140 -44
View File
@@ -24,6 +24,9 @@ import {
_MODELDIR_CHECK_ON,
_MODELDIR_CHECK_OFF,
_serverEntryHtml,
_serverDefaultHtml,
_applyServerSelectColor,
_syncServerSelectColors,
_copyText,
// Import cookbook.js WITHOUT a ?v= query — the same plain specifier every other
// importer uses. A query mismatch loads cookbook.js twice as two separate modules
@@ -34,10 +37,59 @@ import spinnerModule from './spinner.js';
import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
import { openCookbookDependencies } from './cookbook-diagnosis.js';
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
// 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' };
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm' };
function _normalizeCookbookModelDir(dir) {
const d = String(dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
}
function _wireServerColorPicker(entry) {
const wrap = entry.querySelector('.cookbook-srv-color-wrap');
const select = entry.querySelector('.cookbook-srv-color');
const btn = entry.querySelector('.cookbook-srv-color-btn');
const menu = entry.querySelector('.cookbook-srv-color-menu');
if (!wrap || !select || !btn || !menu || btn.dataset.bound) return;
btn.dataset.bound = '1';
const close = () => {
menu.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
};
const open = () => {
document.querySelectorAll('.cookbook-srv-color-menu').forEach(m => {
if (m !== menu) m.classList.add('hidden');
});
menu.classList.remove('hidden');
btn.setAttribute('aria-expanded', 'true');
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.classList.contains('hidden')) open();
else close();
});
menu.querySelectorAll('.cookbook-srv-color-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const color = item.dataset.color || '';
select.value = color;
const label = item.querySelector('span:last-child')?.textContent || 'Auto';
const labelEl = btn.querySelector('.cookbook-srv-color-label');
if (labelEl) labelEl.textContent = label;
const swatch = item.style.getPropertyValue('--swatch-color') || color;
if (/^#[0-9a-fA-F]{6}$/.test(swatch.trim())) {
entry.style.setProperty('--cookbook-server-color', swatch.trim());
wrap.style.setProperty('--cookbook-server-color', swatch.trim());
}
menu.querySelectorAll('.cookbook-srv-color-item').forEach(b => b.classList.toggle('active', b === item));
close();
select.dispatchEvent(new Event('change', { bubbles: true }));
});
});
document.addEventListener('click', close);
}
// Pre-launch: ask the deps API whether the chosen backend is present on
// the target server. Returns true if it's good to go, false if we should
@@ -80,7 +132,6 @@ export let _cachedModelIds = null; // repo IDs already downloaded
// after the user has switched servers.
let _hwfitFetchToken = 0;
let _dismissedHwChips = new Set();
let _hwfitAutoScanStarted = new Set();
// Permanently removed (X-clicked) chips. Separate from _dismissedHwChips
// so the ranker treats "off" and "removed" the same (both ignore the
// hardware) but the UI keeps "off" chips visible to toggle back on,
@@ -407,15 +458,12 @@ function _manualDisplaySystem(sys, manual) {
// Signature of everything that affects the result list, so we never paint a
// cached list under mismatched filters.
function _scanSig() {
const sortEl = document.getElementById('hwfit-sort');
const tc = document.getElementById('hwfit-gpu-toggles');
return JSON.stringify({
h: _envState.remoteHost || '',
hk: _currentServerValue(),
u: document.getElementById('hwfit-usecase')?.value || '',
s: document.getElementById('hwfit-search')?.value?.trim() || '',
o: sortEl?.value || 'newest',
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
q: document.getElementById('hwfit-quant')?.value || '',
c: _ctxValue(),
g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '',
@@ -534,6 +582,13 @@ function _olParseSize(s) {
function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
const out = [];
if (!Array.isArray(libModels)) return out;
const _ramFitLevel = (need, budget) => {
if (!need || !budget || need > budget) return 'too_tight';
const ratio = need / budget;
if (ratio <= 0.50) return 'perfect';
if (ratio <= 0.78) return 'good';
return 'marginal';
};
for (const m of libModels) {
const sizes = (Array.isArray(m.sizes) && m.sizes.length) ? m.sizes : ['latest'];
for (const sz of sizes) {
@@ -544,10 +599,10 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
if (vramGb && vramAvail) {
if (vramGb <= vramAvail * 0.6) fitLevel = 'perfect';
else if (vramGb <= vramAvail) fitLevel = 'good';
else if (ramAvail && vramGb <= ramAvail) fitLevel = 'marginal';
else if (ramAvail && vramGb <= ramAvail) fitLevel = _ramFitLevel(vramGb, ramAvail);
else fitLevel = 'too_tight';
} else if (vramGb && ramAvail && vramGb <= ramAvail) {
fitLevel = 'marginal';
fitLevel = _ramFitLevel(vramGb, ramAvail);
}
const tag = `${m.name}:${sz}`;
const paramsLabel = params
@@ -628,21 +683,18 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
loadingTitle.textContent = 'No cached scan yet';
loadingTitle.style.cssText = 'font-size:12px;opacity:0.7;';
const loadingLbl = document.createElement('div');
loadingLbl.textContent = 'Scanning hardware…';
loadingLbl.textContent = 'Loading model list…';
loadingLbl.style.cssText = 'font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;';
loadingDiv.appendChild(loadingTitle);
loadingDiv.appendChild(loadingLbl);
list.innerHTML = '';
list.appendChild(loadingDiv);
if (!_hwfitAutoScanStarted.has(_sig)) {
_hwfitAutoScanStarted.add(_sig);
setTimeout(() => {
if (_tk === _hwfitFetchToken) {
_resetGpuToggleState();
_hwfitFetch(true, { autoFromEmpty: true });
}
}, 60);
}
setTimeout(() => {
if (_tk === _hwfitFetchToken) {
_resetGpuToggleState();
_hwfitFetch(true, { autoFromEmpty: true });
}
}, 60);
return;
}
if (!canKeepPrevious) {
@@ -653,13 +705,15 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
loadingDiv.style.flexDirection = 'column';
loadingDiv.style.gap = '6px';
loadingDiv.appendChild(wp.element);
// Text label like the other cookbook tabs: "Loading…", then if the scan runs
// long (remote SSH hardware probe), switch to "Scanning hardware…".
// Text label like the other cookbook tabs. Only fresh rescans are hardware
// probes; normal refreshes are just model ranking/loading from cached hw.
const loadingLbl = document.createElement('div');
loadingLbl.textContent = 'Loading…';
loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading models…';
loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
loadingDiv.appendChild(loadingLbl);
setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
setTimeout(() => {
if (loadingLbl.isConnected) loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading model list…';
}, 2000);
list.innerHTML = '';
list.appendChild(loadingDiv);
_hwfitCache = null; // no instant paint — clear until the fetch returns
@@ -703,10 +757,6 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
_setLastCacheHost('');
});
}
if (_paintedFromCache && !forceRevalidate) {
try { wp.destroy(); } catch {}
return;
}
try {
const sortBy = document.getElementById('hwfit-sort')?.value || 'newest';
const quantPref = document.getElementById('hwfit-quant')?.value || '';
@@ -722,7 +772,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
if (!hasManualOrDismissed && toggleContainer && toggleContainer._activeGroup) {
gpuGroupOverride = String(toggleContainer._activeGroup);
}
const params = new URLSearchParams({ limit: '80', sort: sortBy });
// Sorting is a table operation, not a different backend query. Fetch a
// broad candidate set once, then sort it client-side so VRAM/Params/etc.
// do not appear to "filter out" rows by returning a different top-80 slice.
const params = new URLSearchParams({ limit: '2500', sort: 'score' });
if (fresh) params.set('fresh', '1'); // bypass the hardware-scan cache
if (search) params.set('search', search);
if (remoteHost) {
@@ -743,6 +796,9 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
// 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
}
if (!isImageMode) {
if (useCase) params.set('use_case', useCase);
if (quantPref) params.set('quant', quantPref);
@@ -1198,9 +1254,9 @@ function _modeLabel(model) {
export const _hwfitColumns = [
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
{ key: 'newest', label: 'Model (latest)', cls: 'hwfit-name' },
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
{ key: 'params',label: 'Param', cls: 'hwfit-c-params' },
{ key: null, label: 'Quant', cls: 'hwfit-c-quant' },
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
{ key: 'context',label: 'Ctx', cls: 'hwfit-c-ctx' },
{ key: 'speed', label: 'Speed', cls: 'hwfit-c-speed' },
{ key: 'score', label: 'Score', cls: 'hwfit-c-score' },
@@ -1301,13 +1357,13 @@ export function _hwfitRenderList(el, models) {
}
}
html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}</span>`;
html += `<span class="hwfit-col hwfit-c-params">${esc(pcount)}</span>`;
html += `<span class="hwfit-col hwfit-c-vram" title="Estimated loaded footprint for this quant/backend/context, including model weights and KV/runtime allowance.">${vramLabel}</span>`;
html += `<span class="hwfit-col hwfit-c-params" title="Original total model parameters, not quantized storage size.">${esc(pcount)}</span>`;
// Truncate the Quant cell to 9 chars + ellipsis so long tags like
// "FP4-MoE-Mixed" don't push neighboring columns. Full tag stays in title.
const _qRaw = m.quant || '?';
const _qShort = _qRaw.length > 9 ? _qRaw.slice(0, 9) + '…' : _qRaw;
html += `<span class="hwfit-col hwfit-c-quant" title="${esc(_qRaw)}">${esc(_qShort)}</span>`;
html += `<span class="hwfit-col hwfit-c-vram">${vramLabel}</span>`;
html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
html += `<span class="hwfit-col hwfit-c-score">${score}</span>`;
@@ -1356,14 +1412,13 @@ export function _hwfitRenderList(el, models) {
if (e.target.closest('[data-fit-dot]')) {
const on = !e.target.classList.contains('active');
try { localStorage.setItem('hwfit_fit_only_v1', on ? '1' : '0'); } catch {}
// Un-toggling the fit filter (off → showing too-tight rows again) is
// typically because the user wants to see the LARGE models they can't
// run yet — re-sort by VRAM descending so the biggest surface first.
// Un-toggling the fit filter should still keep the list usable: show
// nearest/smallest VRAM first, not a wall of impossible 7000G rows.
if (!on) {
const sortSel = document.getElementById('hwfit-sort');
if (sortSel) {
sortSel.value = 'vram';
sortSel.dataset.reverse = '0'; // descending (biggest first)
sortSel.dataset.reverse = '1'; // ascending (smallest VRAM first)
}
}
_hwfitCache = null;
@@ -1379,7 +1434,9 @@ export function _hwfitRenderList(el, models) {
sel.dataset.reverse = sel.dataset.reverse === '1' ? '0' : '1';
} else {
sel.value = sortKey;
sel.dataset.reverse = '0';
// VRAM is most useful as "what fits / closest fit first"; descending
// buries qwen/gemma-sized rows below absurd impossible footprints.
sel.dataset.reverse = sortKey === 'vram' ? '1' : '0';
}
_hwfitFetch();
});
@@ -1751,6 +1808,9 @@ export function _expandModelRow(row, modelData) {
cmd += ` --context-length ${maxCtx}`;
cmd += ` --mem-fraction-static ${gpuUtil}`;
cmd += ' --trust-remote-code';
} 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}`;
} 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)`;
@@ -1868,6 +1928,7 @@ const _HWFIT_ENGINE_GLYPHS = {
'': '<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"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line><circle cx="8" cy="6" r="2" fill="currentColor" stroke="none"></circle><circle cx="16" cy="12" r="2" fill="currentColor" stroke="none"></circle><circle cx="10" cy="18" r="2" fill="currentColor" stroke="none"></circle></svg>',
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><path d="M14 4l4 9 3-9"></path></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><path d="M16 6v12"></path><path d="M20 6v12"></path></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><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"></path></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"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"></path></svg>',
@@ -2040,15 +2101,22 @@ export function _hwfitInit() {
];
for (const sel of selectors) {
if (!sel) continue;
const currentVal = sel.value;
let html = `<option value="local">Local</option>`;
const currentVal = sel.value || _currentServerValue();
const localSrv = _envState.servers.find(s => !s.host || String(s.host).toLowerCase() === 'local') || {};
const localColor = /^#[0-9a-fA-F]{6}$/.test(String(localSrv.color || '').trim()) ? String(localSrv.color).trim() : '';
const localLabel = localSrv.name || 'Local';
let html = `<option value="local"${localColor ? ` style="color:${uiModule.esc(localColor)};"` : ''}>${uiModule.esc(localColor ? `${localLabel}` : localLabel)}</option>`;
_envState.servers.forEach((s, i) => {
if (!s.host) return;
const label = s.name || s.host || `Server ${i + 1}`;
html += `<option value="${i}">${uiModule.esc(label)}</option>`;
const color = /^#[0-9a-fA-F]{6}$/.test(String(s.color || '').trim()) ? String(s.color).trim() : '';
html += `<option value="${uiModule.esc(_serverKey(s))}"${color ? ` style="color:${uiModule.esc(color)};"` : ''}>${uiModule.esc(color ? `${label}` : label)}</option>`;
});
sel.innerHTML = html;
sel.value = currentVal;
if (sel.selectedIndex < 0) sel.value = _currentServerValue();
if (sel.selectedIndex < 0) sel.value = 'local';
_applyServerSelectColor(sel);
}
}
@@ -2066,13 +2134,15 @@ export function _hwfitInit() {
const port = row.querySelector('.cookbook-srv-port')?.value.trim() || '';
const env = row.querySelector('.cookbook-srv-env')?.value || 'none';
const envPath = row.querySelector('.cookbook-srv-path')?.value.trim() || '';
const colorRaw = row.querySelector('.cookbook-srv-color')?.value?.trim() || '';
const color = /^#[0-9a-fA-F]{6}$/.test(colorRaw) ? colorRaw : '';
// Collect model directories from tags. Read the authoritative data-dir
// attribute, not textContent \u2014 the tag now also holds a download-target
// icon, and textContent would fold the icon/\u2716 glyph into the path.
const dirTags = entry.querySelectorAll('.cookbook-modeldir-tag');
const modelDirs = [];
dirTags.forEach(tag => {
const d = (tag.dataset.dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
const d = _normalizeCookbookModelDir(tag.dataset.dir || '');
if (d) modelDirs.push(d);
});
if (!modelDirs.length) modelDirs.push('~/.cache/huggingface/hub');
@@ -2080,7 +2150,7 @@ export function _hwfitInit() {
const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
const platform = entry.dataset.platform || '';
_envState.servers.push({ name, host: host || '', port, env, envPath, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform });
_envState.servers.push({ name, host: host || '', port, env, envPath, color, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform });
});
// Do NOT auto-change the selected host here. _syncServers can run while the
// servers DOM is mid-render — host fields that are disabled/readonly read as
@@ -2259,8 +2329,7 @@ export function _hwfitInit() {
document.querySelectorAll('.cookbook-srv-default').forEach(b => {
const on = !!_envState.defaultServer && b.dataset.srvKey === _envState.defaultServer;
b.classList.toggle('active', on);
// Keep the "default" label after the icon (don't overwrite it).
b.innerHTML = (on ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF) + '<span class="cookbook-srv-default-label">default</span>';
b.innerHTML = _serverDefaultHtml(on);
b.title = on ? 'Default server — Cookbook opens here' : 'Make this the default server';
});
// Apply immediately so the dropdowns reflect it without reopening
@@ -2307,10 +2376,28 @@ export function _hwfitInit() {
uiModule.showToast('SSH setup command copied');
});
}
_wireServerColorPicker(entry);
entry.querySelectorAll('input, select').forEach(el => {
el.addEventListener('change', () => {
const selectedBefore = _envState.remoteHost || '';
const entryHost = entry.querySelector('.cookbook-srv-host')?.value?.trim() || '';
const color = entry.querySelector('.cookbook-srv-color')?.value?.trim() || '';
const hasColor = /^#[0-9a-fA-F]{6}$/.test(color);
const colorWrap = entry.querySelector('.cookbook-srv-color-wrap');
if (hasColor) {
entry.style.setProperty('--cookbook-server-color', color);
colorWrap?.style.setProperty('--cookbook-server-color', color);
} else {
const autoColor = (colorWrap?.style.getPropertyValue('--cookbook-server-color') || entry.style.getPropertyValue('--cookbook-server-color') || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(autoColor)) {
entry.style.setProperty('--cookbook-server-color', autoColor);
colorWrap?.style.setProperty('--cookbook-server-color', autoColor);
} else {
entry.style.removeProperty('--cookbook-server-color');
colorWrap?.style.removeProperty('--cookbook-server-color');
}
}
colorWrap?.classList.toggle('has-color', true);
_syncServers();
_rebuildServerSelect();
if (selectedBefore && selectedBefore === entryHost) {
@@ -2320,6 +2407,11 @@ export function _hwfitInit() {
if (!entry.querySelector('.cookbook-server-key-panel')?.classList.contains('hidden')) {
_populateServerKeyPanel(entry, false);
}
const saveBtn = entry.querySelector('.cookbook-server-save-btn.saved');
if (saveBtn) {
saveBtn.classList.remove('saved');
saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><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';
}
});
});
// Manual connectivity test after editing host or port. Existing saved
@@ -2341,7 +2433,7 @@ export function _hwfitInit() {
_hwfitFetch();
});
}
// Save button on a brand-new server entry: persist + confirm with a check.
// Save button: persist + confirm with a check.
const saveBtn = entry.querySelector('.cookbook-server-save-btn');
if (saveBtn && !saveBtn.dataset.bound) {
saveBtn.dataset.bound = '1';
@@ -2359,6 +2451,7 @@ export function _hwfitInit() {
} catch (_) {}
saveBtn.classList.add('saved');
saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><polyline points="20 6 9 17 4 12"/></svg>Saved';
uiModule.showToast('Server saved');
});
}
const rmBtn = entry.querySelector('.cookbook-server-rm');
@@ -2520,7 +2613,7 @@ export function _hwfitInit() {
// Build the new entry with the SAME template as existing servers (Model
// Directory header, default checkmark, platform icon) \u2014 isNew swaps the
// delete button for a Save button. forceRemote keeps it editable.
const blank = { host: '', name: '', port: '', env: 'none', envPath: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] };
const blank = { host: '', name: '', port: '', env: 'none', envPath: '', color: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] };
const wrap = document.createElement('div');
wrap.innerHTML = _serverEntryHtml(blank, idx, _envState.defaultServer || '', true, true);
const entry = wrap.firstElementChild;
@@ -2554,6 +2647,7 @@ export function _hwfitInit() {
}
}
_persistEnvState();
_applyServerSelectColor(serverSelect);
// Keep the other server dropdowns (Download / Cache / Deps) in sync. The
// download-input button reads #hwfit-dl-server *directly*, so without this
// it kept its old value and downloads went to the wrong host even
@@ -2561,6 +2655,7 @@ export function _hwfitInit() {
document.querySelectorAll('#hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (!sel || sel.tagName !== 'SELECT') return;
sel.value = _currentServerValue();
_applyServerSelectColor(sel);
});
_hwfitCache = null;
// Reset GPU-toggle state (no flicker) so the new server's hardware re-renders.
@@ -2568,5 +2663,6 @@ export function _hwfitInit() {
_hwfitFetch();
});
}
_syncServerSelectColors();
}
+276 -43
View File
@@ -60,6 +60,11 @@ if (typeof window !== 'undefined' && !window._tagScrollGuardWired) {
export const _MODELDIR_CHECK_OFF = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
export const _MODELDIR_CHECK_ON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>';
function _normalizeCookbookModelDir(dir) {
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
}
// Monochrome platform glyphs (currentColor) for a server's OS tag: a penguin for
// Linux, the four-pane logo for Windows, an Android robot for Termux/Android.
function _platformIcon(platform) {
@@ -170,6 +175,92 @@ function _gemma4ThinkingChatTemplateArg(modelName) {
: '';
}
const _SERVER_COLOR_CHOICES = [
['', 'Auto'],
['#bd93f9', 'Purple'],
['#ff79c6', 'Pink'],
['#fca5a5', 'Red'],
['#93c5fd', 'Blue'],
['#86efac', 'Green'],
['#d6b37a', 'Bronze'],
['#111827', 'Black'],
['#f8fafc', 'White'],
['#c0c4cc', 'Silver'],
['#d9f99d', 'Lime'],
['#ccfbf1', 'Mint'],
];
const _SERVER_AUTO_COLOR_VALUES = _SERVER_COLOR_CHOICES.slice(1).map(([value]) => value);
function _serverColorValue(value) {
const v = String(value || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : '';
}
function _serverColor(s) {
return _serverColorValue(s && s.color);
}
function _serverColorLabel(color) {
const hit = _SERVER_COLOR_CHOICES.find(([value]) => value === color);
return hit ? hit[1] : 'Auto';
}
function _autoServerColor(index) {
const servers = Array.isArray(_envState.servers) ? _envState.servers : [];
const explicit = new Set(servers.map(s => _serverColor(s)).filter(Boolean));
const used = new Set(explicit);
for (let j = 0; j <= index; j++) {
const s = servers[j] || {};
const explicitColor = _serverColor(s);
if (explicitColor) continue;
const picked = _SERVER_AUTO_COLOR_VALUES.find(c => !used.has(c)) || _SERVER_AUTO_COLOR_VALUES[j % _SERVER_AUTO_COLOR_VALUES.length] || '';
if (j === index) return picked;
if (picked) used.add(picked);
}
return _SERVER_AUTO_COLOR_VALUES[index % _SERVER_AUTO_COLOR_VALUES.length] || '';
}
function _resolvedServerColor(s, index) {
return _serverColor(s) || _autoServerColor(index);
}
function _serverOptionLabel(label, color) {
return color ? `${label}` : label;
}
function _serverOptionStyle(color) {
return color ? ` style="color:${esc(color)};"` : '';
}
function _serverColorOptionStyle(color) {
if (!color) return ' style="background:var(--bg);color:var(--fg);"';
const c = String(color).toLowerCase();
const fg = (c === '#111827') ? '#f8fafc' : (c === '#f8fafc' || c === '#fca5a5' || c === '#93c5fd' || c === '#86efac' || c === '#d9f99d' || c === '#ccfbf1' || c === '#c0c4cc') ? '#111827' : color;
return ` style="color:${esc(fg)};background-color:color-mix(in srgb, ${esc(color)} 28%, var(--bg));"`;
}
function _serverColorForValue(value) {
const s = _serverByVal(value);
const idx = Array.isArray(_envState.servers) ? _envState.servers.indexOf(s) : -1;
return s ? _resolvedServerColor(s, idx >= 0 ? idx : 0) : '';
}
export function _applyServerSelectColor(sel) {
if (!sel || sel.tagName !== 'SELECT') return;
const color = _serverColorForValue(sel.value);
if (color) {
sel.style.setProperty('--cookbook-server-color', color);
sel.classList.add('cookbook-server-select-colored');
} else {
sel.style.removeProperty('--cookbook-server-color');
sel.classList.remove('cookbook-server-select-colored');
}
}
export function _syncServerSelectColors(root = document) {
root.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(_applyServerSelectColor);
}
function _buildServerOpts(excludeLocal = false) {
// The local server is ALWAYS represented by the synthetic value="local" option
// (showing its custom name from the "server name" feature). We must therefore
@@ -177,7 +268,8 @@ function _buildServerOpts(excludeLocal = false) {
const _localIdx = _envState.servers.findIndex(_isLocalEntry);
const _localSrv = _localIdx >= 0 ? _envState.servers[_localIdx] : null;
const _localLabel = (_localSrv && _localSrv.name) ? _localSrv.name : 'Local';
let html = `<option value="local"${!_envState.remoteHost ? ' selected' : ''}>${esc(_localLabel)}</option>`;
const _localColor = _localSrv ? _resolvedServerColor(_localSrv, _localIdx) : '';
let html = `<option value="local"${_serverOptionStyle(_localColor)}${!_envState.remoteHost ? ' selected' : ''}>${esc(_serverOptionLabel(_localLabel, _localColor))}</option>`;
const selectedKey = _envState.remoteServerKey || '';
let legacyHostSelected = false;
for (let i = 0; i < _envState.servers.length; i++) {
@@ -186,12 +278,13 @@ function _buildServerOpts(excludeLocal = false) {
if (excludeLocal && _isLocalEntry(s)) continue;
const label = s.name || s.host || `Server ${i + 1}`;
const value = _serverKey(s);
const color = _resolvedServerColor(s, i);
let selected = selectedKey ? value === selectedKey : false;
if (!selectedKey && _envState.remoteHost === s.host && !legacyHostSelected) {
selected = true;
legacyHostSelected = true;
}
html += `<option value="${esc(value)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
html += `<option value="${esc(value)}"${_serverOptionStyle(color)}${selected ? ' selected' : ''}>${esc(_serverOptionLabel(label, color))}</option>`;
}
return html;
}
@@ -345,6 +438,8 @@ export function _detectReasoningParser(modelName) {
// MiniMax M2 / M2.5 / M2.7 — released with a dedicated parser. Catch M2
// before plain "minimax" so M2.x doesn't fall through to a wrong parser.
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2';
// DeepSeek-V4 has a dedicated parser in SGLang. Keep it before R1/V3.
if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseek-v4';
// DeepSeek-R1 / V3-Thinking / V3.1-Thinking variants. Bare V3/V3.1 (non-
// thinking) skip this — they're not reasoning models.
if (n.includes('deepseek') && (n.includes('r1') || n.includes('thinking'))) return 'deepseek_r1';
@@ -382,6 +477,7 @@ export function _detectToolParser(modelName) {
if (n.includes('llama-4') || n.includes('llama4')) return 'llama4_json';
if (n.includes('llama') || n.includes('nemotron')) return 'llama3_json';
if (n.includes('mistral') || n.includes('mixtral')) return 'mistral';
if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseekv4';
if (n.includes('deepseek-v3')) return 'deepseek_v3';
if (n.includes('deepseek')) return 'deepseek_v3';
if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3';
@@ -409,7 +505,7 @@ export function _detectBackend(model) {
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
return { backend: 'unsupported', label: 'Unsupported' };
return { backend: 'mlx', label: 'MLX' };
}
const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || ['FP8', 'FP4', 'MXFP4', 'NF4', 'INT4', 'INT8', 'W4A16', 'W8A8', 'W8A16'].includes(q) || /\b(awq|gptq|fp8|fp4|nvfp4|mxfp4|nf4|int4|int8|w4a16|w8a8|w8a16)\b/i.test(_nm);
const hasGgufFile = Array.isArray(model.gguf_files)
@@ -442,7 +538,7 @@ export function _detectBackend(model) {
// don't run on macOS; vLLM-native quantized models are already filtered out
// of metal Cookbook results, so llama.cpp is always the right engine here.
if (['metal', 'mps', 'apple'].includes(sysBackend)) {
return { backend: 'llamacpp', label: 'llama.cpp' };
return { backend: 'mlx', label: 'MLX' };
}
// ROCm/AMD machines should not blindly default HF safetensors models to
@@ -540,13 +636,32 @@ function _venvRootFromPath(path) {
return p;
}
function _venvLooksWrongForPlatform(path, platform) {
const p = String(path || '').trim();
const plat = String(platform || '').toLowerCase();
if (!p || !plat) return false;
if ((plat === 'darwin' || plat === 'macos') && /^\/(?:home|usr\/local\/cuda|opt\/conda)\//.test(p)) return true;
if ((plat === 'linux' || plat === 'termux') && /^\/(?:Users|opt\/homebrew)\//.test(p)) return true;
return false;
}
function _isDeepSeekV4Model(modelName) {
const n = String(modelName || '').toLowerCase();
return n.includes('deepseek') && /\bv[-_]?4\b/.test(n);
}
function _envHasKey(envText, key) {
return String(envText || '').split(/\s+/).some(part => part.startsWith(`${key}=`));
}
export function _buildServeCmd(f, modelName, backend) {
// When a venv is configured on the chosen server, use the venv's binaries
// by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non-
// interactive sessions often leave a user-site install (~/.local/bin/vllm)
// ahead of the venv's bin, so the WRONG vllm gets launched even with the
// venv activated. Absolute path sidesteps the whole PATH question.
const _formVenv = (f.venv ?? '').toString().trim();
let _formVenv = (f.venv ?? '').toString().trim();
if (_venvLooksWrongForPlatform(_formVenv, f.platform)) _formVenv = '';
const _activeVenvPath = _venvRootFromPath(_formVenv || (_envState.env === 'venv' ? (_envState.envPath || '') : ''));
const _venvBin = _activeVenvPath ? (_activeVenvPath + '/bin/') : '';
const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm';
@@ -618,14 +733,19 @@ export function _buildServeCmd(f, modelName, backend) {
// button strip is the only source for which devices to pin.
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
cmd += _gpuEnvPrefix(gpuId);
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
const _isDsv4 = _isDeepSeekV4Model(modelName);
let _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
if (_isDsv4 && !_envHasKey(_extraEnv, 'SGLANG_DSV4_COMPRESS_STATE_DTYPE')) {
_extraEnv = (`SGLANG_DSV4_COMPRESS_STATE_DTYPE=bf16 ${_extraEnv}`).trim();
}
if (_extraEnv) cmd += _extraEnv + ' ';
cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName} --host 0.0.0.0 --port ${f.port || '30000'}`;
const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName);
if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`;
if (f.tp && f.tp !== '1') cmd += ` --tp ${f.tp}`;
if (f.ctx) cmd += ` --context-length ${f.ctx}`;
if (f.gpu_mem && f.gpu_mem !== '0.90') cmd += ` --mem-fraction-static ${f.gpu_mem}`;
const _memFraction = _isDsv4 && (!f.gpu_mem || f.gpu_mem === '0.90') ? '0.80' : f.gpu_mem;
if (_memFraction && _memFraction !== '0.90') cmd += ` --mem-fraction-static ${_memFraction}`;
if (f.dtype && f.dtype !== 'auto') cmd += ` --dtype ${f.dtype}`;
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-running-requests ${f.max_seqs.toString().trim()}`;
if (f.trust_remote) cmd += ' --trust-remote-code';
@@ -638,6 +758,14 @@ export function _buildServeCmd(f, modelName, backend) {
}
if (!f.prefix_cache) cmd += ' --disable-radix-cache';
if (f.enforce_eager) cmd += ' --disable-cuda-graph';
const _decodeGraph = String(f.sglang_decode_graph || '').trim();
if (!f.enforce_eager && _decodeGraph === 'disabled') {
cmd += ' --cuda-graph-backend-decode disabled';
} else if (!f.enforce_eager && _decodeGraph === 'bs16') {
cmd += ' --cuda-graph-max-bs-decode 16';
} else if (!f.enforce_eager && _isDsv4 && !/\s--cuda-graph-max-bs-decode\b/.test(cmd) && !/\s--cuda-graph-backend-decode\b/.test(cmd)) {
cmd += ' --cuda-graph-backend-decode disabled';
}
} else if (backend === 'llamacpp') {
const ggufPath = f._gguf_path || 'model.gguf';
// GPU list — read from gpus (button strip); fall back to gpu_id for
@@ -812,6 +940,13 @@ export function _buildServeCmd(f, modelName, backend) {
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') {
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'}`;
if (/minimax|mini-max/i.test(modelName)) {
cmd += ' --temp 0.7 --top-p 0.9 --max-tokens 2048';
}
}
return cmd;
}
@@ -933,6 +1068,7 @@ async function _fetchDependencies() {
const pkgs = data.packages || [];
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
const _systemInstallable = new Set(['tmux']);
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
@@ -944,8 +1080,12 @@ async function _fetchDependencies() {
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">&#9662;</span></button>`;
if (isSystemDep) {
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
if (pkg.applicable !== false && _systemInstallable.has(pkg.name)) {
return `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(pkg.name)}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="${depTip}">Install</button>`;
}
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`;
const depStyle = pkg.name === 'docker' ? ' style="width:87.7px;justify-content:center;"' : '';
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}"${depStyle}>${depLabel}</span>`;
}
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
};
@@ -957,6 +1097,7 @@ async function _fetchDependencies() {
const _DEP_GLYPHS = {
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>',
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>',
@@ -1121,22 +1262,32 @@ async function _fetchDependencies() {
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) {
let targetServer = null;
if (isLocalOnly) {
_envState.remoteHost = '';
_envState.env = 'none';
_envState.envPath = '';
} else {
const depsServerSel = document.getElementById('hwfit-deps-server');
if (depsServerSel) _applyServerSelection(depsServerSel.value);
if (depsServerSel) {
targetServer = _serverByVal(depsServerSel.value);
_applyServerSelection(depsServerSel.value);
}
}
const targetHost = isLocalOnly ? 'this server' : (_envState.remoteHost || 'local');
const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local');
const targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || '');
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`
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
// only add `--user --break-system-packages` when there's no env —
// for PEP-668-locked system pythons (Arch, newer Debian).
const _inEnv = _envState.env === 'venv' || _envState.env === 'conda';
const _pipFlags = (!_isWindows() && !_inEnv) ? ' --user --break-system-packages' : '';
const _inEnv = targetEnv === 'venv' || targetEnv === 'conda';
const _platform = String(targetPlatform || '').toLowerCase();
const _isAppleTarget = _platform === 'darwin' || _platform === 'macos' || _platform.includes('mac os');
const _pipFlags = (!_isWindows() && !_inEnv) ? (_isAppleTarget ? ' --user' : ' --user --break-system-packages') : '';
// Use the venv's python3 by absolute path when configured. Even with the
// env_prefix sourcing activate, SSH non-interactive sessions sometimes
// pick a `python3` ahead of the venv's bin on PATH, so the install
@@ -1144,35 +1295,35 @@ async function _fetchDependencies() {
let _py;
if (_isWindows()) {
_py = 'python';
} else if (_envState.env === 'venv' && _envState.envPath) {
_py = `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`;
} else if (targetEnv === 'venv' && targetEnvPath) {
_py = `${targetEnvPath.replace(/\/+$/, '')}/bin/python3`;
} else {
_py = 'python3';
}
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
let envPrefix = '';
if (_isWindows()) {
if (_envState.env === 'venv' && _envState.envPath) {
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'conda activate ' + _psQuote(_envState.envPath);
if (targetEnv === 'venv' && targetEnvPath) {
envPrefix = '& ' + _psQuote(targetEnvPath.endsWith('\\Scripts\\Activate.ps1') ? targetEnvPath : targetEnvPath + '\\Scripts\\Activate.ps1');
} else if (targetEnv === 'conda' && targetEnvPath) {
envPrefix = 'conda activate ' + _psQuote(targetEnvPath);
}
} else {
if (_envState.env === 'venv' && _envState.envPath) {
const p = _envState.envPath;
if (targetEnv === 'venv' && targetEnvPath) {
const p = targetEnvPath;
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
} else if (targetEnv === 'conda' && targetEnvPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(targetEnvPath);
}
}
try {
const reqBody = {
repo_id: pipName,
cmd: cmd,
remote_host: _envState.remoteHost || undefined,
ssh_port: _getPort(_envState.remoteHost) || undefined,
remote_host: targetRemoteHost || undefined,
ssh_port: _getPort(targetRemoteHost) || undefined,
env_prefix: envPrefix || undefined,
platform: _envState.platform || undefined,
platform: targetPlatform || undefined,
};
const res = await fetch('/api/model/serve', {
method: 'POST', credentials: 'same-origin',
@@ -1196,7 +1347,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: _envState.remoteHost || '', _dep: true, env_path: _envState.envPath || '' };
const payload = { repo_id: pipName, _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}...`);
@@ -1334,7 +1485,7 @@ async function _fetchDependencies() {
// 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('Build-deps install failed: ' + String(reason).slice(0, 300) + _suffix, {
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300) + _suffix, {
duration: 25000,
action: _resolvedCmd ? 'Copy command' : 'OK',
onAction: async () => {
@@ -1638,9 +1789,47 @@ function _applyServerSelection(val) {
sel.value = _want;
if (sel.selectedIndex < 0) sel.value = 'local';
}
_applyServerSelectColor(sel);
});
}
async function _refreshScanDownloadTarget() {
const btn = document.getElementById('hwfit-hw-refresh-btn');
if (btn && btn.disabled) return;
const selectedVal = document.getElementById('hwfit-server-select')?.value || _currentServerValue();
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.55';
btn.style.cursor = 'wait';
}
try {
if (selectedVal) _applyServerSelection(selectedVal);
const ok = await _syncFromServer().catch((e) => {
console.warn('[cookbook] explicit server sync failed', e);
return false;
});
if (ok) {
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
if (selectedVal) _applyServerSelection(selectedVal);
}
_resetGpuToggleState();
await Promise.allSettled([
_hwfitFetch(true),
_fetchCachedModels(true),
]);
if (uiModule?.showToast) uiModule.showToast('Refreshed selected server');
} catch (e) {
console.warn('[cookbook] scan/download refresh failed', e);
if (uiModule?.showError) uiModule.showError('Refresh failed: ' + (e?.message || e));
} finally {
if (btn) {
btn.disabled = false;
btn.style.opacity = '';
btn.style.cursor = '';
}
}
}
function _wireTabEvents(body) {
// Tab switching
body.querySelectorAll('.cookbook-tab').forEach(tab => {
@@ -1701,17 +1890,18 @@ function _wireTabEvents(body) {
const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || '';
const env = entry.querySelector('.cookbook-srv-env')?.value || 'none';
const envPath = entry.querySelector('.cookbook-srv-path')?.value?.trim() || '';
const color = _serverColorValue(entry.querySelector('.cookbook-srv-color')?.value || '');
const platform = entry.dataset.platform || '';
const dirs = [];
entry.querySelectorAll('.cookbook-modeldir-tag').forEach(tag => {
// Read from data attribute (authoritative) — never parse displayed text
const d = (tag.dataset.dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
const d = _normalizeCookbookModelDir(tag.dataset.dir || '');
if (d) dirs.push(d);
});
// Directory flagged as the download target ('' = default HF cache).
const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
servers.push({ name, host, port, env, envPath, modelDirs: dirs, downloadDir, platform });
servers.push({ name, host, port, env, envPath, color, modelDirs: dirs, downloadDir, platform });
});
_envState.servers = servers;
// Auto-default: when the user has configured EXACTLY ONE remote server
@@ -1737,13 +1927,16 @@ function _wireTabEvents(body) {
Promise.resolve().then(() => {
const _want = _currentServerValue();
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (sel && sel.tagName === 'SELECT') sel.value = _want;
if (sel && sel.tagName === 'SELECT') {
sel.value = _want;
_applyServerSelectColor(sel);
}
});
});
}
// Wire server form inputs
document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => {
document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-color, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => {
el.addEventListener('change', _syncServers);
});
document.querySelectorAll('.cookbook-srv-env').forEach(el => {
@@ -1788,7 +1981,7 @@ function _wireTabEvents(body) {
if (cacheDirEl) cacheDirEl.value = srv.modelDir || '~/.cache/huggingface/hub';
const dirsEl = document.querySelector('.cookbook-serve-dirs');
if (dirsEl) {
const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
dirsEl.innerHTML = dirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('') +
'<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => {
@@ -1802,7 +1995,22 @@ function _wireTabEvents(body) {
const scanBtn = document.getElementById('hwfit-cache-scan');
if (scanBtn) {
scanBtn.addEventListener('click', () => _fetchCachedModels(true));
scanBtn.addEventListener('click', async () => {
if (scanBtn.disabled) return;
scanBtn.disabled = true;
scanBtn.classList.add('spinning');
try {
await _fetchCachedModels(true);
} finally {
scanBtn.disabled = false;
scanBtn.classList.remove('spinning');
}
});
}
const hwRefreshBtn = document.getElementById('hwfit-hw-refresh-btn');
if (hwRefreshBtn) {
hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget);
}
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
@@ -1822,6 +2030,7 @@ function _wireTabEvents(body) {
_fetchDependencies();
});
}
_syncServerSelectColors(body);
// "Rebuild llama.cpp" clears the cached build so the next serve recompiles.
// The serve bootstrap only builds llama-server when it is missing from PATH,
@@ -2522,11 +2731,30 @@ function _wireTabEvents(body) {
// (Model Directory header, default-server checkmark, trash delete, platform icon).
// forceRemote renders an editable remote entry even before a host is typed
// (a new server's host is empty, which would otherwise read as "Local").
export function _serverDefaultHtml(active) {
const check = active ? '<span class="hwfit-hf-check cookbook-srv-default-check" title="Default server" style="font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;">✓</span>' : '';
return `${check}<span class="cookbook-srv-default-label">default</span>`;
}
export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
const isLocal = (forceRemote || isNew) ? false : (!s.host || s.host === 'local');
const envOpts = [['none', 'None'], ['venv', 'venv'], ['conda', 'conda']].map(([value, label]) => `<option value="${value}"${s.env === value ? ' selected' : ''}>${label}</option>`).join('');
const srvColor = _serverColor(s);
const resolvedSrvColor = _resolvedServerColor(s, i);
const colorOpts = _SERVER_COLOR_CHOICES.map(([value, label]) => {
const displayLabel = label;
return `<option value="${esc(value)}"${_serverColorOptionStyle(value)}${srvColor === value ? ' selected' : ''}>${esc(displayLabel)}</option>`;
}).join('');
const selectedColorLabel = srvColor ? _serverColorLabel(srvColor) : `Auto · ${_serverColorLabel(resolvedSrvColor)}`;
const colorMenu = _SERVER_COLOR_CHOICES.map(([value, label]) => {
const active = value === srvColor;
const swatchColor = value || resolvedSrvColor;
const rowLabel = value ? label : `Auto · ${_serverColorLabel(resolvedSrvColor)}`;
const swatch = swatchColor ? ` style="--swatch-color:${esc(swatchColor)};"` : '';
return `<button type="button" class="cookbook-srv-color-item${active ? ' active' : ''}" data-color="${esc(value)}"${swatch}><span class="cookbook-srv-color-item-dot"></span><span>${esc(rowLabel)}</span></button>`;
}).join('');
let html = '';
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}">`;
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}"${resolvedSrvColor ? ` style="--cookbook-server-color:${esc(resolvedSrvColor)};"` : ''}>`;
const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`));
const _srvKey = isLocal ? 'local' : (s.host || '');
const _isDefaultSrv = (defaultServer || '') === _srvKey;
@@ -2542,12 +2770,13 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
// sense once the server is saved.
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${_checkBtn}${_keyBtn}<button class="cookbook-server-cancel-btn" title="Discard this new server" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button></span>`;
} else {
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${!isLocal ? _checkBtn + _keyBtn : ''}<span class="cookbook-srv-default${_isDefaultSrv ? ' active' : ''}" title="${_isDefaultSrv ? 'Default server — Cookbook opens here' : 'Make this the default server'}" data-srv-key="${esc(_srvKey)}">${_isDefaultSrv ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}<span class="cookbook-srv-default-label">default</span></span></span>`;
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${!isLocal ? _checkBtn + _keyBtn : ''}<span class="cookbook-srv-default${_isDefaultSrv ? ' active' : ''}" title="${_isDefaultSrv ? 'Default server — Cookbook opens here' : 'Make this the default server'}" data-srv-key="${esc(_srvKey)}">${_serverDefaultHtml(_isDefaultSrv)}</span></span>`;
}
html += `</span>`;
html += `<div class="cookbook-server-row">`;
html += `<input type="text" class="hwfit-sf cookbook-srv-name" value="${esc(s.name || (isLocal ? 'Local' : ''))}" placeholder="Name (optional)" style="width:92px;flex-shrink:0;" />`;
html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:214.5px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`;
html += `<span class="cookbook-srv-color-wrap has-color" title="Server color"><select class="hwfit-sf cookbook-srv-color" aria-hidden="true" tabindex="-1">${colorOpts}</select><button type="button" class="hwfit-sf cookbook-srv-color-btn" aria-haspopup="listbox" aria-expanded="false"><span class="cookbook-srv-color-dot" aria-hidden="true"></span><span class="cookbook-srv-color-label">${esc(selectedColorLabel)}</span><svg class="cookbook-srv-color-caret" width="9" height="9" 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 class="cookbook-srv-color-menu hidden" role="listbox">${colorMenu}</div></span>`;
html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:184px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`;
html += `<input type="text" class="hwfit-sf cookbook-srv-port" value="${esc(s.port || '')}" placeholder="Port" title="SSH port (default 22)" style="width:48px;flex-shrink:0;" ${isLocal ? 'readonly' : ''} />`;
html += `<select class="hwfit-sf cookbook-srv-env">${envOpts}</select>`;
html += `<input type="text" class="hwfit-sf cookbook-srv-path" value="${esc(s.envPath || '')}" placeholder="${s.platform === 'windows' ? 'venv/conda env' : '~/venv or conda-env'}" />`;
@@ -2567,13 +2796,15 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
html += `<span class="cookbook-modeldir-tag${isDefault ? ' cookbook-modeldir-default' : ''}${isTarget ? ' cookbook-modeldir-target' : ''}" data-dir-idx="${j}" data-dir="${esc(modelDirs[j])}">${dlBtn} ${esc(modelDirs[j])}${rmBtn}</span>`;
}
html += `<button class="cookbook-modeldir-add" title="Add model directory">+ Add</button>`;
const _btnStyle = 'margin-left:auto;position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
const _btnBaseStyle = 'position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
const _btnPushStyle = `margin-left:auto;${_btnBaseStyle}`;
if (isNew) {
// A brand-new server: Save (confirm) sits where Delete would be; Cancel is
// top-right in the title. Save confirms with a checkmark (auto-saves on edit too).
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
} else if (!isLocal) {
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><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>Delete</button>`;
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><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>Delete</button>`;
html += `<button class="cookbook-server-save-btn" title="Save server changes" style="${_btnBaseStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
}
html += `</div>`;
if (!isLocal) {
@@ -2709,6 +2940,7 @@ function _renderRecipes() {
html += '<option value="">Engine</option>';
html += '<option value="llamacpp">llama.cpp</option>';
html += '<option value="ollama">Ollama</option>';
html += '<option value="mlx">MLX</option>';
html += '<option value="vllm">vLLM</option>';
html += '<option value="sglang">SGLang</option>';
html += '<option value="diffusers">Diffusers</option>';
@@ -2726,7 +2958,7 @@ function _renderRecipes() {
html += '<span class="hwfit-quant-wrap">';
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">';
html += '<option value="" selected>Quant</option>';
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
html += '<option value="Q4_K_M">Q4 / AWQ</option><option value="Q8_0">Q8</option>';
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>';
@@ -2744,9 +2976,8 @@ function _renderRecipes() {
html += _buildServerOpts(false);
html += '</select>';
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
// (Rescan button removed — Edit handles manual hardware updates;
// automatic re-probe runs on container restart.)
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">';
@@ -2787,7 +3018,7 @@ function _renderRecipes() {
html += '<h2 style="margin:0;padding:0;line-height:1;">Serve <span id="serve-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>';
html += '</div>';
const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {};
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
html += '<div class="cookbook-serve-dirs" style="margin-top:6px;">';
html += _srvDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('');
html += '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
@@ -2797,6 +3028,7 @@ function _renderRecipes() {
html += '<select class="memory-sort-select" id="serve-sort" style="height:24px;">';
html += '<option value="name">Name</option><option value="size-desc">Size \u2193</option><option value="size-asc">Size \u2191</option><option value="recent">Recent</option>';
html += '</select>';
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-cache-scan" title="Refresh cached models on selected server" aria-label="Refresh cached models on selected server"><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>';
html += '</div>';
html += '<div class="memory-toolbar" style="margin-top:8px;">';
html += '<div class="memory-category-filters">';
@@ -3170,6 +3402,7 @@ document.addEventListener('cookbook:state-synced', () => {
if (isVisible()) {
const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || '';
if (activeTab === 'Running') _renderRunningTab();
else if (activeTab === 'Serve') _rerenderCachedModels();
}
});
+23 -6
View File
@@ -484,8 +484,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
// they disagree on the active host. The servers LIST is consistent, so we look
// up the matching server to get its env / path / platform / port.
let host;
let selectedServer = null;
let selectedServerKey = '';
if (hostOverride !== undefined) {
host = hostOverride || '';
selectedServer = host ? (_serverByVal?.(host) || (_envState.servers || []).find(s => s.host === host) || null) : null;
selectedServerKey = selectedServer ? (typeof window.cookbookModule?._serverKey === 'function' ? window.cookbookModule._serverKey(selectedServer) : '') : '';
} else {
// No explicit host passed: resolve from the visible server dropdown rather
// than _envState.remoteHost (unreliable — multiple state copies disagree).
@@ -496,15 +500,21 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
const _dsrv = (_ssv && _ssv !== 'local') ? (_serverByVal?.(_ssv) || _envState.servers[parseInt(_ssv)]) : null;
if (_dsrv) {
host = _dsrv.host;
selectedServer = _dsrv;
selectedServerKey = _ssv || '';
} else if (ssEl && ssEl.value === 'local') {
host = '';
} else {
host = _envState.remoteHost || '';
selectedServer = host ? ((_envState.servers || []).find(s => s.host === host) || _serverByVal?.(host) || null) : null;
}
}
const srv = _serverByVal?.(_envState.remoteServerKey || host) || {};
const env = host ? (srv.env || 'none') : (_envState.env || 'none');
const srv = selectedServer || _serverByVal?.(host) || {};
let env = host ? (srv.env || 'none') : (_envState.env || 'none');
const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
if ((!env || env === 'none') && envPath) {
env = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(envPath) ? 'venv' : env;
}
const platform = host ? (srv.platform || '') : (_envState.platform || '');
const isWin = host ? (platform === 'windows') : _isWindows();
@@ -515,7 +525,13 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
// resumes cached partials more reliably.
if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
if (host) {
payload.remote_host = host;
if (selectedServerKey && selectedServerKey !== 'local') payload.remote_server_key = selectedServerKey;
if (srv.name) payload.remote_server_name = srv.name;
const _sp = srv.port || _getPort(host);
if (_sp) payload.ssh_port = _sp;
}
if (platform) payload.platform = platform;
// If this server has a directory flagged as the download target, send it so
// the backend downloads into <dir>/<model> instead of the default HF cache.
@@ -562,11 +578,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
if (zombieCandidate) {
try {
const _zh = zombieCandidate.remoteHost || '';
const _zPort = (_serverByVal?.(_envState.remoteServerKey || _zh)
const _zPort = (_serverByVal?.(zombieCandidate.remoteServerKey || zombieCandidate.payload?.remote_server_key || _zh)
|| (_envState.servers || []).find(s => s.host === _zh) || {}).port;
const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
const _sshSf = _zh ? `'` : '';
const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _probePrefix = _zh ? 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ' : '';
const _probeCmd = `${_sshPf}${_probePrefix}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _r = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -593,7 +610,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
if (activeOnHost) {
const queueId = `queue-${Date.now().toString(36)}`;
const allTasks = _loadTasks();
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host, remoteServerKey: payload.remote_server_key || '', remoteServerName: payload.remote_server_name || '', sshPort: payload.ssh_port || '', platform: payload.platform || '' });
_saveTasks(allTasks);
_renderRunningTab();
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
+306 -25
View File
@@ -57,9 +57,24 @@ function _downloadDisplayName(name, task) {
return part ? `${name} · ${part}` : name;
}
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".
const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName);
const base = (!rawName || looksLikeLauncher)
? String(payload?.repo_id || payload?.repo || '').split('/').pop()
: rawName;
const include = payload?.include || '';
if (!include || String(base || '').includes(' · ')) return base || rawName || 'download';
const part = _ggufDisplayPartFromPath(String(include).replace(/\*/g, ''));
return part ? `${base} · ${part}` : (base || rawName || 'download');
}
function _taskDisplayName(task) {
const name = String(task?.name || '').trim();
if (task?.type === 'download') return _downloadDisplayName(name, task);
if (task?.type === 'download') return _downloadDisplayName(_downloadNameFromPayload(name, task?.payload), task);
if (task?.type !== 'serve') return name;
const gguf = task?.payload?._fields?.gguf_file || task?.payload?.gguf_file || '';
if (!gguf || name.includes(' · ')) return name;
@@ -98,7 +113,7 @@ function _downloadOutputLooksActive(task) {
function _canClearTask(task) {
if (!task || task.status === 'running') return false;
if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) return false;
if (task.type === 'serve' && (task.status === 'ready' || (!['error', 'crashed', 'failed', 'completed'].includes(task.status) && _serveOutputLooksReady(task)))) return false;
// If the tmux output still shows an in-flight download, the task isn't
// actually finished — hide the clear/check pill so it doesn't show on a
// task that's still doing work. (The next render will reflect this and
@@ -334,6 +349,34 @@ function _taskServerSelection(task) {
return { host, server, key };
}
function _serverColorForTaskGroup(key, tasks) {
const firstTask = Array.isArray(tasks) ? tasks[0] : null;
const host = firstTask?.remoteHost || firstTask?.payload?.remote_host || '';
const savedKey = firstTask?.remoteServerKey || firstTask?.payload?.remote_server_key || key || '';
const server = (savedKey ? _serverByVal?.(savedKey) : null)
|| (key ? _serverByVal?.(key) : null)
|| (host ? _serverByVal?.(host) : null)
|| (key === 'local' || !key ? (_envState?.servers || []).find(s => !s.host || String(s.host).toLowerCase() === 'local') : null)
|| null;
const color = String(server?.color || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '';
}
function _serverHeaderStyle(color) {
if (!color) return '';
const c = color.toLowerCase();
const accent = (c === '#ffffff' || c === '#f8fafc') ? '#cbd5e1'
: (c === '#111827' || c === '#000000') ? '#64748b'
: color;
return ` style="--cookbook-server-color:${esc(color)};--cookbook-server-accent:${esc(accent)};"`;
}
function _shouldAutoExpandTaskOutput(task) {
return task?.type === 'download'
&& !task?.payload?._dep
&& ['running', 'queued', 'error', 'crashed'].includes(String(task?.status || ''));
}
function _selectTaskServer(task) {
const { host, server, key } = _taskServerSelection(task);
_envState.remoteHost = host;
@@ -366,6 +409,7 @@ let _soloExpandTaskId = null;
const TASKS_KEY = 'cookbook-tasks';
const STORAGE_KEY = 'cookbook-presets';
const SERVE_STATE_KEY = 'cookbook-serve-state';
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
// Polling / timeout intervals
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
@@ -489,7 +533,7 @@ function _refreshModelsAfterEndpointChange() {
pickerLabel.innerHTML = '<span style="opacity:0.4;">refreshing…</span>';
}
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true);
window.modelsModule.refreshModels(false);
}
setTimeout(() => {
if (!window.sessionModule) return;
@@ -545,6 +589,53 @@ function _endpointFromAdvertisedUrl(rawUrl, currentHost, fallbackPort = '11434')
}
}
function _serveExpectedModel(task) {
const fields = task?.payload?._fields || {};
return String(
fields.served_model_name ||
fields.model_path ||
task?.payload?.repo_id ||
task?.model ||
task?.name ||
''
).trim();
}
function _modelIdMatchesExpected(modelId, expected) {
const got = String(modelId || '').trim().toLowerCase();
const want = String(expected || '').trim().toLowerCase();
if (!got || !want) return true;
if (got === want) return true;
const gotBase = got.split('/').pop();
const wantBase = want.split('/').pop();
return gotBase === wantBase || got.includes(wantBase) || want.includes(gotBase);
}
function _endpointMatchesServe(ep, task) {
const expected = _serveExpectedModel(task);
const models = [...(ep?.models || []), ...(ep?.pinned_models || [])];
if (!models.length) return true;
return models.some(mid => _modelIdMatchesExpected(mid, expected));
}
function _markServeEndpointMismatch(task, ep, host, port) {
const expected = _serveExpectedModel(task);
const actual = (ep?.models || []).join(', ') || 'no models';
const msg = `Port ${host}:${port} answered, but it is serving ${actual}, not ${expected || task?.name || 'the launched model'}. The new serve likely failed or the port is occupied by an older server.`;
_updateTask(task.sessionId || task.session_id, {
status: 'error',
_serveReady: false,
_endpointAdded: false,
output: `${task.output || ''}\n\n${msg}`.trim(),
});
uiModule.showError(msg);
}
function _appendPinnedServeModel(fd, task) {
const expected = _serveExpectedModel(task);
if (expected) fd.append('pinned_models', expected);
}
// ── Download queue — runs one at a time per server ──
function _processQueue() {
@@ -891,13 +982,17 @@ function _taskRemoteHost(task) {
return task?.remoteHost || task?.payload?.remote_host || '';
}
function _remoteTmuxPrefix() {
return 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ';
}
export function _tmuxCmd(task, tmuxArgs) {
if (_isWindows(task)) {
return _winSessionCmd(task, tmuxArgs);
}
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} 'tmux ${tmuxArgs}' 2>/dev/null`;
return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null`;
}
return `tmux ${tmuxArgs} 2>/dev/null`;
}
@@ -930,7 +1025,7 @@ function _winSessionCmd(task, tmuxArgs) {
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
return _winPowerShellCmd(task, ps);
}
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
return host ? `ssh ${pf}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
}
function _winPowerShellCmd(task, ps) {
@@ -957,7 +1052,7 @@ export function _tmuxGracefulKill(task) {
}
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
}
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
}
@@ -984,7 +1079,7 @@ export function _tmuxForceKill(task) {
`tmux kill-session -t ${sid} 2>/dev/null`;
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(inner)}`;
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`;
}
return inner;
}
@@ -1001,7 +1096,7 @@ export function _tmuxIsAliveCheck(task) {
const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`;
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(inner)}`;
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`;
}
return inner;
}
@@ -1225,8 +1320,13 @@ function _syncToServer() {
presets: _loadPresets(),
env: _envState,
serveState: null,
serveFavorites: [],
};
try { state.serveState = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)); } catch {}
try {
const favorites = JSON.parse(localStorage.getItem(SERVE_FAVORITES_KEY) || '[]');
state.serveFavorites = Array.isArray(favorites) ? favorites.filter(Boolean).map(String) : [];
} catch {}
await fetch('/api/cookbook/state', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -1236,6 +1336,10 @@ function _syncToServer() {
}, 400);
}
document.addEventListener('cookbook:state-dirty', () => {
_syncToServer();
});
// Normalize state from server: collapse legacy duplicate keys to canonical form.
// - server.modelDir (singular) → server.modelDirs[0] (canonical)
// - strip ✕/✖ pollution from modelDirs
@@ -1317,6 +1421,9 @@ export async function _syncFromServer() {
if (state.serveState) {
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(state.serveState));
}
if (Array.isArray(state.serveFavorites)) {
localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(state.serveFavorites.filter(Boolean).map(String)));
}
document.dispatchEvent(new CustomEvent('cookbook:state-synced', { detail: state }));
return true;
} catch { return false; }
@@ -1384,6 +1491,7 @@ async function _retryDownload(name, payload, replaceSessionId = '') {
const tasks = _loadTasks();
const task = tasks.find(t => t.sessionId === replaceSessionId);
if (task) {
task.name = _downloadNameFromPayload(name || task.name, _payload);
task.id = data.session_id;
task.sessionId = data.session_id;
task.status = 'running';
@@ -1510,6 +1618,11 @@ export async function _serveAutoRetryReplace(panel, flag, value) {
_animateOutThenRemove(taskEl, taskId);
let newCmd = task.payload._cmd;
if (flag === '--cuda-graph-backend-decode') {
newCmd = newCmd.replace(/\s+--cuda-graph-max-bs-decode(?:\s+\S+|=\S+)/g, '');
} else if (flag === '--cuda-graph-max-bs-decode') {
newCmd = newCmd.replace(/\s+--cuda-graph-backend-decode(?:\s+\S+|=\S+)/g, '');
}
const re = new RegExp(flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s+\\S+');
if (re.test(newCmd)) {
newCmd = newCmd.replace(re, `${flag} ${value}`);
@@ -1641,6 +1754,7 @@ 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_lm.server') ? 'mlx'
: cmd.includes('diffusion_server') ? 'diffusers'
: cmd.includes('sglang') ? 'sglang'
: cmd.includes('ollama') ? 'ollama' : 'vllm',
@@ -1678,6 +1792,100 @@ function _parseServeCmdToFields(cmd) {
return fields;
}
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);
}
function _selectedGpuIndexes(gpus) {
const raw = String(gpus || '').trim();
if (!raw) return null;
const out = new Set();
raw.split(',').forEach(part => {
const p = part.trim();
const range = p.match(/^(\d+)\s*-\s*(\d+)$/);
if (range) {
const a = parseInt(range[1], 10);
const b = parseInt(range[2], 10);
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i);
return;
}
const n = parseInt(p, 10);
if (Number.isFinite(n)) out.add(n);
});
return out.size ? out : null;
}
function _gbFromMb(mb) {
const n = Number(mb || 0);
if (!Number.isFinite(n) || n <= 0) return '';
return n >= 1024 ? `${(n / 1024).toFixed(n >= 10240 ? 0 : 1)}G` : `${Math.round(n)}M`;
}
function _gpuPreflightIssues(data, selected) {
const backend = String(data?.backend || data?.source || '').toLowerCase();
const isCuda = backend.includes('cuda') || String(data?.source || '').toLowerCase().includes('nvidia');
const rows = Array.isArray(data?.gpus) ? data.gpus : [];
const issues = [];
rows.forEach(g => {
const idx = Number(g?.index);
if (selected && !selected.has(idx)) return;
const procs = Array.isArray(g?.processes) ? g.processes : [];
if (procs.length) {
procs.slice(0, 3).forEach(p => {
const name = String(p?.name || 'process').split(/[\\/]/).pop();
const used = _gbFromMb(p?.used_mb);
issues.push(`GPU ${idx}: ${name}${p?.pid ? ` #${p.pid}` : ''}${used ? ` (${used})` : ''}`);
});
if (procs.length > 3) issues.push(`GPU ${idx}: +${procs.length - 3} more process${procs.length - 3 === 1 ? '' : 'es'}`);
return;
}
const total = Number(g?.total_mb || 0);
const free = Number(g?.free_mb || 0);
const used = Number(g?.used_mb || 0);
const freeRatio = total > 0 ? free / total : 1;
// CUDA can have display/runtime crumbs; warn only for meaningful occupied memory.
if (isCuda && used > 4096 && freeRatio < 0.9) {
issues.push(`GPU ${idx}: ${_gbFromMb(used)} already used (${_gbFromMb(free)} free)`);
} else if (!isCuda && total > 0 && freeRatio < 0.2) {
issues.push(`${g?.name || `GPU ${idx}`}: low free memory (${_gbFromMb(free)} free of ${_gbFromMb(total)})`);
} else if (!isCuda && g?.busy && total <= 0) {
issues.push(`${g?.name || `GPU ${idx}`}: GPU device is busy`);
}
});
return issues;
}
async function _confirmGpuPreflight(reqBody, shortName, repo, cmd) {
if (!_serveCmdNeedsGpuPreflight(cmd, repo)) return true;
const params = new URLSearchParams();
if (reqBody.remote_host) params.set('host', reqBody.remote_host);
if (reqBody.ssh_port) params.set('ssh_port', reqBody.ssh_port);
try {
const res = await fetch(`/api/cookbook/gpus${params.toString() ? `?${params.toString()}` : ''}`, {
method: 'GET',
credentials: 'same-origin',
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) return true;
const selected = _selectedGpuIndexes(reqBody.gpus);
const issues = _gpuPreflightIssues(data, selected);
if (!issues.length) return true;
const where = reqBody.remote_host || 'local';
const list = issues.slice(0, 6).join('; ');
const more = issues.length > 6 ? `; +${issues.length - 6} more` : '';
const msg = `GPU preflight found existing load on ${where}: ${list}${more}. Launch ${shortName || 'model'} anyway?`;
const confirm = window.styledConfirm || uiModule?.styledConfirm;
if (confirm) return await confirm(msg, { confirmText: 'Launch anyway', cancelText: 'Cancel' });
return window.confirm ? window.confirm(msg) : true;
} catch (e) {
console.warn('[cookbook] GPU preflight failed; allowing launch', e);
return true;
}
}
export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride, targetMeta = null) {
// Host resolution mirrors the download path: when the caller passes an explicit
// host (resolved from the dropdown the user actually picked), use it and look
@@ -1761,11 +1969,16 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
ssh_port: _getPort(_serverMetaKey || _host) || undefined,
env_prefix: envPrefix || undefined,
hf_token: _envState.hfToken || undefined,
gpus: _envState.gpus || undefined,
gpus: _usedGpus || undefined,
platform: _hplatform || undefined,
};
try {
const _preflightOk = await _confirmGpuPreflight(reqBody, shortName, repo, cmd);
if (!_preflightOk) {
uiModule.showToast('Launch cancelled — GPU is already in use');
return;
}
const res = await fetch('/api/model/serve', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -1989,7 +2202,8 @@ export function _renderRunningTab() {
// green when reachable, red if any serve task on it is crashed/unreachable.
const _secDot = (key && allTasks.some(_serveTaskFailed)) ? 'fail' : 'ok';
const _dotTitle = key ? (_secDot === 'fail' ? 'Server not responding' : 'Reachable') : 'Local (this machine)';
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header" data-collapse="${bodyId}"><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
const _srvColor = _serverColorForTaskGroup(key || 'local', allTasks);
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header${_srvColor ? ' has-server-color' : ''}" data-collapse="${bodyId}"${_serverHeaderStyle(_srvColor)}><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
}
}
@@ -2170,7 +2384,7 @@ export function _renderRunningTab() {
<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 ? ' 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>
<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>
`;
const _waveEl = el.querySelector('.cookbook-task-wave');
@@ -2305,7 +2519,23 @@ export function _renderRunningTab() {
el.querySelector('.cookbook-task-header').addEventListener('click', (e) => {
if (e.target.closest('button')) return;
const wrap = el.querySelector('.cookbook-output-wrap');
if (wrap) wrap.classList.toggle('cookbook-task-collapsed');
if (!wrap) return;
const isOpening = wrap.classList.contains('cookbook-task-collapsed');
wrap.classList.toggle('cookbook-task-collapsed');
if (isOpening) {
_expandedTaskIds.add(task.sessionId);
_collapsedTaskIds.delete(task.sessionId);
if (task.sessionId && ['serve', 'download'].includes(task.type || '')) {
_reconnectTask(el, task);
}
} else {
_collapsedTaskIds.add(task.sessionId);
_expandedTaskIds.delete(task.sessionId);
if (el._abort) {
try { el._abort.abort(); } catch {}
el._abort = null;
}
}
});
// Wire menu button (also fire from a long-press anywhere on the card so
@@ -2344,10 +2574,18 @@ export function _renderRunningTab() {
el.addEventListener('touchcancel', _lpCancel, { passive: true });
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
const existing = document.querySelector('.cookbook-task-dropdown');
if (existing && existing._anchor === menuBtn) {
if (typeof existing._dismiss === 'function') existing._dismiss();
else existing.remove();
return;
}
document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
const dropdown = document.createElement('div');
dropdown.className = 'cookbook-task-dropdown';
dropdown._anchor = menuBtn;
menuBtn.classList.add('cookbook-menu-active');
const items = [];
// ── Run section ─────────────────────────────────────────────
@@ -2549,7 +2787,7 @@ export function _renderRunningTab() {
}
const closeHandler = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== menuBtn) {
if (!dropdown.contains(ev.target) && ev.target !== menuBtn && !menuBtn.contains(ev.target)) {
_cleanup();
}
};
@@ -2561,6 +2799,7 @@ export function _renderRunningTab() {
const _cleanup = () => {
_unreg(); _unreg = () => {};
dropdown.remove();
menuBtn.classList.remove('cookbook-menu-active');
document.removeEventListener('click', closeHandler);
window.removeEventListener('scroll', scrollClose, true);
window.visualViewport?.removeEventListener('scroll', scrollClose);
@@ -2732,7 +2971,9 @@ export function _renderRunningTab() {
// responds; without this, the user opens the Running tab and sees
// only the placeholder ("Launched by scheduled task …") because
// _reconnectTask never fires for status 'ready'/'loading'/'warming'.
if (_isRunningTabVisible() && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status)) {
const _wrapForStream = el.querySelector('.cookbook-output-wrap');
const _streamExpanded = _wrapForStream && !_wrapForStream.classList.contains('cookbook-task-collapsed');
if (_isRunningTabVisible() && _streamExpanded && task.sessionId && ['serve', 'download'].includes(task.type || '')) {
_reconnectTask(el, task);
}
}
@@ -2764,13 +3005,19 @@ export function _renderRunningTab() {
// ── Reconnect task (polling loop) ──
async function _reconnectTask(el, task) {
if (!el || !task) return;
const wrap = el.querySelector('.cookbook-output-wrap');
if (!_isRunningTabVisible() || !wrap || wrap.classList.contains('cookbook-task-collapsed')) return;
if (el._abort && !el._abort.signal?.aborted) return;
const output = el.querySelector('.cookbook-output-pre');
if (!output) return;
const controller = new AbortController();
el._abort = controller;
let failCount = 0;
while (!controller.signal.aborted) {
if (!el.isConnected) {
const liveWrap = el.querySelector('.cookbook-output-wrap');
if (!el.isConnected || !_isRunningTabVisible() || !liveWrap || liveWrap.classList.contains('cookbook-task-collapsed')) {
controller.abort();
break;
}
@@ -3358,13 +3605,17 @@ async function _reconnectTask(el, task) {
// endpoints server-side. Mark so we don't retry, but STILL
// refresh the picker (and probe until online) so the new model
// shows up without the user having to manually refresh.
const _ex = eps.find(e => e.base_url === baseUrl);
if (_ex && !_endpointMatchesServe(_ex, task)) {
_markServeEndpointMismatch(task, _ex, host, port);
return null;
}
task._endpointAdded = true;
_updateTask(task.sessionId, { _endpointAdded: true });
_autoSaveWorkingConfig(task); // endpoint live → remember these settings
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } }));
const _ex = eps.find(e => e.base_url === baseUrl);
if (_ex && _ex.id && !(_ex.models || []).length) _probeEndpointUntilOnline(_ex.id, host, port);
return null;
}
@@ -3374,6 +3625,7 @@ async function _reconnectTask(el, task) {
fd.append('name', task.name);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, task.remoteHost || '');
_appendPinnedServeModel(fd, task);
if (_isDiffusion) fd.append('model_type', 'image');
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
})
@@ -3392,7 +3644,7 @@ async function _reconnectTask(el, task) {
}
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } }));
const _trySelectModel = async (attempt) => {
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
const items = window.modelsModule?.getCachedItems?.() || [];
for (const item of items) {
if (item.offline) continue;
@@ -3479,6 +3731,16 @@ function _isRunningTabVisible() {
return activeTab === 'Running';
}
function _isCookbookVisible() {
try {
if (window.cookbookModule && typeof window.cookbookModule.isVisible === 'function') {
return !!window.cookbookModule.isVisible();
}
} catch (_) {}
const modal = document.getElementById('cookbook-modal');
return !!modal && !modal.classList.contains('hidden');
}
function _foregroundChatBusy() {
try {
return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0);
@@ -3780,6 +4042,7 @@ function _stopBackgroundMonitor() {
// the endpoint reports models, then refreshes the picker. Bounded so a
// genuinely-dead server doesn't poll forever.
async function _probeEndpointUntilOnline(epId, host, port) {
if (!_isCookbookVisible() || _foregroundChatBusy()) return;
if (!epId) return;
// Big models (e.g. 70B+) can take several minutes to load weights before
// the server answers /v1/models. Probe for up to ~5 min, easing the
@@ -3788,6 +4051,7 @@ async function _probeEndpointUntilOnline(epId, host, port) {
for (let i = 0; i < MAX_TRIES; i++) {
const interval = i < 12 ? 5000 : 10000; // 5s for the first minute, then 10s
await new Promise(r => setTimeout(r, interval));
if (!_isCookbookVisible() || _foregroundChatBusy()) return;
try {
// Hit the probe endpoint — it re-probes server-side and updates
// cached_models. We consume (and discard) the SSE stream.
@@ -3797,7 +4061,7 @@ async function _probeEndpointUntilOnline(epId, host, port) {
const eps = await fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []);
const ep = (eps || []).find(e => e.id === epId);
if (ep && (ep.models || []).length) {
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', {
detail: { baseUrl: ep.base_url || `http://${host}:${port}/v1`, host, port, model: (ep.models || [])[0] || '' },
@@ -3891,7 +4155,8 @@ async function _pollBackgroundStatus() {
// "stopped" by the backend (its pip package is never in the HF cache the
// dead-session check inspects). Recover "done" from the retained output's
// exit-0 sentinel so a clean install isn't downgraded to crashed.
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
const combinedOutput = `${task.output || ''}\n${live.output_tail || ''}`;
const depDone = !!task.payload?._dep && _depInstallSucceeded(combinedOutput);
// A finished model download whose tmux pane is gone is also reported
// "stopped" (the dead-session check can miss the landed snapshot).
// Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted
@@ -3901,19 +4166,29 @@ async function _pollBackgroundStatus() {
// off the conclusive exit sentinel only, never the `/snapshots/` path,
// which can be printed mid-stream for multi-file downloads.
const downloadDone = task.type === 'download'
&& String(task.output || '').includes('DOWNLOAD_OK');
const nextStatus = live.status === 'completed'
&& String(combinedOutput || '').includes('DOWNLOAD_OK');
const serveReady = task.type === 'serve'
&& (live.status === 'ready' || _serveOutputLooksReady({ ...task, output: live.output_tail || task.output || '' }));
const completedByOutput = depDone || downloadDone;
const nextStatus = completedByOutput
? 'done'
: (serveReady
? 'ready'
: (live.status === 'completed'
? 'done'
: (live.status === 'error'
? 'error'
: (live.status === 'stopped'
? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
: null));
: null))));
if (nextStatus && task.status !== nextStatus) {
updates.status = nextStatus;
if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task);
}
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status) {
if (serveReady && !task._serveReady) {
updates._serveReady = true;
}
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status && !serveReady && !completedByOutput) {
updates.status = live.status === 'ready' ? 'ready' : 'running';
}
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
@@ -3993,6 +4268,11 @@ async function _pollBackgroundStatus() {
const hostPort = `${host}:${port}`;
const existing = eps.find(e => e.base_url === baseUrl || e.base_url.includes(hostPort) || e.name === t.model);
if (existing) {
const taskForMatch = localTask || { sessionId: t.session_id, name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } };
if (!_endpointMatchesServe(existing, taskForMatch)) {
_markServeEndpointMismatch(taskForMatch, existing, host, port);
return null;
}
// 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.
@@ -4004,6 +4284,7 @@ async function _pollBackgroundStatus() {
fd.append('name', t.model);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || '');
_appendPinnedServeModel(fd, localTask || { name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } });
if (_isDiffusion) fd.append('model_type', 'image');
if (_supportsTools) fd.append('supports_tools', 'true');
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
@@ -4016,7 +4297,7 @@ async function _pollBackgroundStatus() {
// probe, so it lands "offline". Retry-probe in the background
// until /v1/models responds — no manual enable/disable needed.
if (data && data.id) _probeEndpointUntilOnline(data.id, host, port);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
}
})
+112 -29
View File
@@ -49,11 +49,25 @@ let _cachedAllModels = [];
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
function _normalizeCookbookModelDir(dir) {
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
}
function _readCachedModelScan(sig) {
try {
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
const entry = all[sig];
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) return entry.data || null;
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) {
const data = entry.data || null;
const models = Array.isArray(data?.models) ? data.models : [];
const staleDownloading = models.some(m =>
(m?.status === 'downloading' || m?.has_incomplete) && !_isActivelyDownloading(m?.repo_id)
);
if (!staleDownloading) return data;
delete all[sig];
localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all));
}
} catch {}
return null;
}
@@ -83,6 +97,7 @@ function _loadServeFavorites() {
function _saveServeFavorites(favorites) {
try {
localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(Array.from(favorites || [])));
document.dispatchEvent(new CustomEvent('cookbook:state-dirty', { detail: { key: SERVE_FAVORITES_KEY } }));
} catch {}
}
@@ -218,7 +233,21 @@ function _shellSplitForPreview(cmd) {
}
function _formatServeCmdPreview(cmd) {
const raw = String(cmd || '');
let raw = String(cmd || '');
const mlxDeepSeekV4Compat = /\bmlx_lm\.server\b/i.test(raw)
&& /--model\s+['"]?mlx-community\/[^'"\s]*deepseek-v4/i.test(raw);
if (mlxDeepSeekV4Compat) {
const modelMatch = raw.match(/--model\s+(['"]?)(mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*)\1/i);
const homeMatch = raw.match(/((?:\/Users|\/home)\/[^/\s'"]+)/);
const shortName = modelMatch?.[2]?.split('/').pop();
if (homeMatch && shortName) {
const shimPath = `${homeMatch[1]}/.cache/odysseus/mlx-shims/${shortName}`;
raw = raw.replace(
/--model\s+(['"]?)mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*\1/i,
`--model '${shimPath}'`
);
}
}
if (raw.startsWith('MODEL_FILE=$({')) {
const marker = /&&\s+([A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)?(?:llama-server|python3?\s+-m\s+llama_cpp\.server)\b/;
const match = raw.match(marker);
@@ -233,7 +262,7 @@ function _formatServeCmdPreview(cmd) {
const lines = [];
let i = 0;
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) {
lines.push(tokens[i]);
lines.push(`export ${tokens[i]}`);
i++;
}
if (tokens[i]) {
@@ -254,11 +283,43 @@ function _formatServeCmdPreview(cmd) {
lines.push(t);
}
}
return lines.join('\n');
const envCount = lines.findIndex(line => !line.startsWith('export '));
const firstCmdLine = envCount < 0 ? lines.length : envCount;
const formatted = lines.map((line, idx) => {
const isCommandPart = idx >= firstCmdLine;
const hasNextCommandPart = lines.slice(idx + 1).some(next => !next.startsWith('export '));
return isCommandPart && hasNextCommandPart ? `${line} \\` : line;
}).join('\n');
if (mlxDeepSeekV4Compat) {
return [
'# Odysseus runtime compatibility: using sanitized MLX DeepSeek-V4 shim.',
formatted,
].join('\n');
}
return formatted;
}
function _normalizeServeCmdForLaunch(cmd) {
return String(cmd || '')
let raw = String(cmd || '');
const lines = raw.split(/\r?\n/)
.map(s => s.trim().replace(/\s*\\$/, '').trim())
.filter(s => s && !s.startsWith('#'));
if (lines.some(line => /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=/.test(line))) {
const env = [];
const body = [];
for (const line of lines) {
const m = line.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*=.*)$/);
if (m) {
env.push(m[1]);
} else if (/^[A-Za-z_][A-Za-z0-9_]*=\S+$/.test(line)) {
env.push(line);
} else {
body.push(line);
}
}
raw = [...env, ...body].join(' ');
}
return raw
.replace(/MODEL_FILE=\$\(\{\s+/g, 'MODEL_FILE=$({ ')
.replace(/\s+\}\s+\|\s+head\s+-1\)/g, ' } | head -1)')
.replace(/\s*;\s*/g, '; ')
@@ -567,7 +628,13 @@ function _selectedServeTarget(panel) {
host = server?.host || '';
}
}
const venv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || server?.envPath || _envState.envPath || '';
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.
const venv = host
? (server?.envPath || typedVenv || '')
: (typedVenv || server?.envPath || _envState.envPath || '');
const label = host
? (server?.name ? `${server.name} (${host})` : host)
: (server?.name || 'local server');
@@ -593,8 +660,8 @@ function _backendChoicesForTarget(target) {
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
}
return _isMetal()
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
? [['mlx','MLX'],['llamacpp','llama.cpp'],['ollama','Ollama']]
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['diffusers','Diffusers']];
}
async function _fetchServeRuntimePackage(panel, backend) {
@@ -602,6 +669,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
vllm: 'vllm',
sglang: 'sglang',
llamacpp: 'llama_cpp',
mlx: 'mlx_lm',
diffusers: 'diffusers',
};
const packageName = packageByBackend[backend];
@@ -621,7 +689,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
}
function _runtimeNoteText(backend, pkg, target) {
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', diffusers: 'Diffusers' };
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', diffusers: 'Diffusers' };
const label = labels[backend] || backend;
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
const note = pkg.status_note || pkg.update_note || '';
@@ -1109,7 +1177,14 @@ function _rerenderCachedModels() {
const repo = item.dataset.repo;
if (!repo) return;
const m = allModels.find(x => x.repo_id === repo);
if (!m || m.status !== 'ready') return;
if (!m) return;
if (m.status !== 'ready') {
if (m.status === 'downloading' && !_isActivelyDownloading(m.repo_id)) {
uiModule.showToast?.('Refreshing cached model status…');
_fetchCachedModels(true);
}
return;
}
// Toggle — close if already open
if (item.classList.contains('doclib-card-expanded')) {
@@ -1266,7 +1341,7 @@ function _rerenderCachedModels() {
// stays as the source-of-truth so every existing change handler
// (updateBackendVisibility, runtime readiness, command builder)
// still fires via dispatchEvent('change') on selection.
panelHtml += `<label>${_l('Engine','Inference engine: vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:32px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;position:relative;top:-4px;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg 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" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
panelHtml += `<label>${_l('Engine','Inference engine: MLX, vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:32px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;position:relative;top:-4px;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg 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" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="host" value="${esc(_es.remoteHost || '')}" />`;
// Inference mode pill (llama.cpp only) — lives directly to the
// RIGHT of Backend in Row 1 so the engine and the GPU/CPU choice
@@ -1328,13 +1403,13 @@ function _rerenderCachedModels() {
// TP / Context / GPU / GPU Mem / Max Seqs / Dtype. Everything else
// (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch)
// moved to the Advanced fold below to keep this row scannable.
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama hwfit-backend-mlx">`;
// Order: Dtype → TP → Context → Max Seqs → GPUs → GPU Mem.
// Dtype moved down from Row 1 to make space for the Inference pill
// (llama.cpp GPU/CPU toggle, llamacpp-only). GPUs lives next to
// GPU Mem so "which devices + how much" sit adjacent. Max Seqs
// follows Context per the "request-shape" cluster.
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
// ctx resets to the model's max on every panel open (the real ctx slider
// lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control).
@@ -1405,7 +1480,9 @@ function _rerenderCachedModels() {
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>`;
// Row 3: Checkboxes (vLLM)
// 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.
// Order: Trust Remote → Auto Tool → Reasoning Parser (when the
// model has one) → Enforce Eager → Prefix Caching. Reasoning
// Parser was previously in a separate row below; the user wanted
@@ -1416,21 +1493,22 @@ function _rerenderCachedModels() {
const _rp_flag = _opts2_row3.flags.find(f => f.includes('--reasoning-parser'));
const _rp_name = _rp_flag ? _rp_flag.split(' ')[1] : '';
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm hwfit-backend-sglang">`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',_isMiniMaxMSeries)?' checked':''} /> Trust Remote Code${_h('Allow model to run custom code from HuggingFace')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',_nativeToolDefault)?' checked':''} /> Auto Tool Choice${_h('Enable function/tool calling for agent mode')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',_isMiniMaxMSeries)?' checked':''} /> Trust Remote Code${_h('SGLang/vLLM: allow model code from HuggingFace via --trust-remote-code')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',_nativeToolDefault)?' checked':''} /> Auto Tool Choice${_h('SGLang/vLLM: enable native tool calling and auto-pick the detected tool-call parser')}</label>`;
// Always-render the Reasoning Parser, Expert Parallel, and MoE Env
// checkboxes — the model-family detection above is a hint, not a
// hard gate. User asked to keep these visible regardless so that
// a borderline-undetected MoE/reasoning model can still toggle
// them without dropping back to the raw command box.
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name || ''}"${sv('reasoning_parser',_reasoningDefault)?' checked':''} /> Reasoning Parser${_rp_name ? ` <span class="hwfit-parser-tag">${_rp_name}</span>` : ''}${_h('Splits <think> tokens into a separate channel. The tag (when shown) is the auto-detected parser; edit the command if you need a different one.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name || ''}"${sv('reasoning_parser',_reasoningDefault)?' checked':''} /> Reasoning Parser${_rp_name ? ` <span class="hwfit-parser-tag">${_rp_name}</span>` : ''}${_h('SGLang/vLLM: splits thinking tokens into a reasoning channel using the detected parser.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Disable CUDA Graphs${_h('vLLM: --enforce-eager. SGLang: --disable-cuda-graph. Slower, but useful for graph-capture crashes.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix / Radix Cache${_h('vLLM: prefix caching. SGLang: RadixAttention prefix cache; when off Odysseus adds --disable-radix-cache.')}</label>`;
// Inline the previously-second vLLM checks row so Expert Parallel /
// Speculative / MoE Env sit next to Prefix Caching with no gap. All
// three are vLLM-only — class-gated so they hide on SGLang. Always
// render so the user can flip them on for any MoE model.
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel"${sv('expert_parallel',_expertParallelDefault)?' checked':''} /> Expert Parallel${_h('MoE: shard expert layers across GPUs. Helps for MiniMax M-series, StepFun Step-3, Qwen3 A3B/A10B/A22B MoE, DeepSeek V3+/R1. Ignored / wasteful on dense models.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel"${sv('expert_parallel',_expertParallelDefault)?' checked':''} /> Expert Parallel${_h('SGLang/vLLM MoE: shard expert layers across GPUs. Useful for DeepSeek/MiniMax/Qwen MoE; avoid on dense models.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-sglang">Decode Graph${_h('SGLang only: tune decode CUDA graph capture. Smaller batch can fix DeepSeek-V4 graph-capture errors; disabled is safest but slower.')} <select class="hwfit-sf" data-field="sglang_decode_graph" style="height:24px;max-width:92px;"><option value=""${sv('sglang_decode_graph','') === '' ? ' selected' : ''}>auto</option><option value="bs16"${sv('sglang_decode_graph','') === 'bs16' ? ' selected' : ''}>bs 16</option><option value="disabled"${sv('sglang_decode_graph','') === 'disabled' ? ' selected' : ''}>off</option></select></label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="language_model_only"${sv('language_model_only',_isMiniMaxM3)?' checked':''} /> Language Model Only${_h('vLLM --language-model-only. Needed by MiniMax M3 text serving when the repo also contains VL components.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="disable_custom_all_reduce"${sv('disable_custom_all_reduce',_isMiniMaxM3)?' checked':''} /> Disable Custom All Reduce${_h('vLLM --disable-custom-all-reduce. Useful for some 8-GPU/nightly configurations.')}</label>`;
{
@@ -1585,6 +1663,7 @@ function _rerenderCachedModels() {
const buildTarget = _selectedServeTarget(panel);
f.host = buildTarget.host || '';
f.platform = buildTarget.platform || '';
f.venv = buildTarget.venv || '';
const hostField = panel.querySelector('[data-field="host"]');
if (hostField) hostField.value = f.host;
const backend = f.backend || 'vllm';
@@ -1871,6 +1950,7 @@ function _rerenderCachedModels() {
const _BACKEND_GLYPHS = {
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>',
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>',
@@ -1984,7 +2064,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', 'diffusers'].includes(backend)) {
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'diffusers'].includes(backend)) {
note.style.display = 'none';
_writeNote('');
return;
@@ -2024,7 +2104,7 @@ 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', diffusers: 'diffusers' }[backend]);
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 link = document.createElement('a');
link.href = '#';
@@ -2079,7 +2159,7 @@ function _rerenderCachedModels() {
});
} else {
const fields = {
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : 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_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',
@@ -3771,7 +3851,8 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
const modelDirs = [];
if (selectedServer && Array.isArray(selectedServer.modelDirs)) {
for (const d of selectedServer.modelDirs) {
if (d && d !== '~/.cache/huggingface/hub') modelDirs.push(d);
const normalized = _normalizeCookbookModelDir(d);
if (normalized && normalized !== '~/.cache/huggingface/hub') modelDirs.push(normalized);
}
}
// Sync the header dir pills to THIS server (the one whose models we're listing).
@@ -3783,7 +3864,7 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
const _allDirs = (Array.isArray(selectedServer.modelDirs) && selectedServer.modelDirs.length
? selectedServer.modelDirs
: [selectedServer.modelDir || '~/.cache/huggingface/hub'])
.map(d => (d || '').replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
.map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
_dirsEl.innerHTML = _allDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('')
+ '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
_dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => {
@@ -3803,10 +3884,12 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
}
if (!allowNetwork) {
_dlWp.destroy();
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:8px;text-align:center;"><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Check this server\'s model cache.</div><button type="button" class="hwfit-gpu-btn serve-empty-scan-btn" style="height:26px;padding:3px 10px;">Scan</button></div>';
list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => {
_fetchCachedModels(true);
});
const wp = spinnerModule.createWhirlpool(22);
list.innerHTML = '<div class="hwfit-loading serve-empty-auto-scan" style="flex-direction:column;gap:8px;text-align:center;"><div class="serve-empty-auto-wp"></div><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Scanning this server\'s model cache…</div></div>';
list.querySelector('.serve-empty-auto-wp')?.appendChild(wp.element);
setTimeout(() => {
if (list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true);
}, 60);
const tagContainer = document.getElementById('serve-tags');
if (tagContainer) tagContainer.innerHTML = '';
return;
+437 -32
View File
@@ -2288,6 +2288,61 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
return header + '\n---\n' + body;
}
function _looksLikeWrappedEmailContent(text) {
const t = String(text || '').replace(/\r\n/g, '\n').trim();
return /\n---\n/.test(t) && /^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder):\s*/im.test(t);
}
function _decodeBase64EmailWrapper(block) {
const compact = String(block || '').replace(/\s+/g, '');
if (compact.length < 24 || compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) return null;
try {
const bin = atob(compact);
let decoded = '';
if (typeof TextDecoder !== 'undefined') {
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
} else {
decoded = decodeURIComponent(escape(bin));
}
decoded = decoded.replace(/\r\n/g, '\n');
return _looksLikeWrappedEmailContent(decoded) ? decoded : null;
} catch (_) {
return null;
}
}
function _sanitizeOutgoingEmailBody(raw) {
let text = String(raw || '').replace(/\r\n/g, '\n');
const trimmed = text.trim();
const decodedWhole = _decodeBase64EmailWrapper(trimmed);
if (decodedWhole) text = _parseEmailHeader(decodedWhole).body || '';
else if (_looksLikeWrappedEmailContent(trimmed)) text = _parseEmailHeader(trimmed).body || '';
const parts = text.split(/(\n{2,})/);
let changed = false;
const clean = parts.map(part => {
if (/^\n+$/.test(part)) return part;
const decoded = _decodeBase64EmailWrapper(part);
if (!decoded) return part;
changed = true;
return _parseEmailHeader(decoded).body || '';
}).join('');
if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') {
const probe = document.createElement('div');
probe.innerHTML = text;
const plain = (probe.innerText || probe.textContent || '').trim();
const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain;
if (plainClean !== plain) return plainClean;
}
return (changed ? clean : text)
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo) {
const uid = String(sourceUid || '').trim();
if (!uid) return '';
@@ -2328,7 +2383,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
references: draft.references ?? fields.references,
sourceUid: draft.sourceUid ?? fields.sourceUid,
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
body: draft.body ?? fields.body,
body: _sanitizeOutgoingEmailBody(draft.body ?? fields.body),
};
}
@@ -2636,6 +2691,15 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
function _splitEmailReplyQuote(text) {
const original = String(text || '');
if (!original) return { body: '', quote: '', stripped: false };
const literal = '---------- Previous message ----------';
const literalIdx = original.indexOf(literal);
if (literalIdx >= 0) {
return {
body: original.slice(0, literalIdx).trim(),
quote: original.slice(literalIdx).trim(),
stripped: true,
};
}
const htmlQuoteOffset = _emailQuoteStartOffset(original);
if (htmlQuoteOffset >= 0) {
const body = original.slice(0, htmlQuoteOffset).trim();
@@ -2756,7 +2820,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
}
function _showEmailFields(doc) {
function _showEmailFields(doc, { applyLocalDraft = true } = {}) {
const emailHeader = document.getElementById('doc-email-header');
const emailActions = document.getElementById('doc-email-actions');
// Show MD toolbar for email too (B, I, etc.)
@@ -2789,11 +2853,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
document.getElementById('doc-editor-code')?.classList.add('email-mode');
document.getElementById('doc-editor-highlight')?.classList.add('email-mode');
let fields = _parseEmailHeader(doc.content || '');
fields = _emailFieldsWithLocalDraft(fields);
if (applyLocalDraft) fields = _emailFieldsWithLocalDraft(fields);
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: true });
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
if (subjectInput && !subjectInput._emailTabBodyBound) {
subjectInput._emailTabBodyBound = true;
@@ -2804,10 +2869,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
});
}
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: true });
_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 });
// 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';
@@ -2930,8 +2995,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: true });
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: preserveEmailHeader });
const hasCcBcc = !!(
fields.cc ||
fields.bcc ||
@@ -3062,6 +3127,292 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
await _uploadComposeFiles(files);
}
let _odysseusAttachMenu = null;
function _closeOdysseusAttachMenu() {
if (_odysseusAttachMenu) {
_odysseusAttachMenu.remove();
_odysseusAttachMenu = null;
}
document.removeEventListener('click', _attachMenuOutsideClick, true);
document.removeEventListener('keydown', _attachMenuEscape, true);
}
function _attachMenuOutsideClick(e) {
if (_odysseusAttachMenu && !_odysseusAttachMenu.contains(e.target)) _closeOdysseusAttachMenu();
}
function _attachMenuEscape(e) {
if (e.key !== 'Escape') return;
_closeOdysseusAttachMenu();
}
function _positionOdysseusAttachMenu(anchor, menu) {
const r = anchor?.getBoundingClientRect?.();
if (!r) return;
menu.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 310))}px`;
menu.style.top = `${r.bottom + 6}px`;
requestAnimationFrame(() => {
const mr = menu.getBoundingClientRect();
if (mr.bottom > window.innerHeight - 8) {
menu.style.top = `${Math.max(8, r.top - mr.height - 6)}px`;
}
});
}
function _odysseusAttachLabel(item, kind) {
if (kind === 'gallery') {
return item.caption || item.prompt || item.filename || 'Gallery image';
}
return item.title || 'Untitled document';
}
async function _stageOdysseusAttachment(kind, id) {
const doc = docs.get(activeDocId);
if (!doc || doc.language !== 'email') return null;
if (!doc._composeAtts) doc._composeAtts = [];
const res = await fetch(`${API_BASE}/api/email/compose-from-odysseus`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, id }),
});
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok || !data?.success) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
doc._composeAtts.push({
token: data.token,
filename: data.filename,
size: data.size || 0,
});
return data;
}
async function _stageOdysseusZip(items) {
const doc = docs.get(activeDocId);
if (!doc || doc.language !== 'email') return null;
if (!doc._composeAtts) doc._composeAtts = [];
const res = await fetch(`${API_BASE}/api/email/compose-from-odysseus-zip`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok || !data?.success) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
doc._composeAtts.push({
token: data.token,
filename: data.filename,
size: data.size || 0,
});
return data;
}
function _afterOdysseusAttachmentsAdded(count, label) {
_renderComposeAttachments();
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
if (uiModule) uiModule.showToast(count > 1 ? `Attached ${count} items` : `Attached ${label || 'item'}`);
}
async function _attachOdysseusItem(kind, id, label, opts = {}) {
try {
const data = await _stageOdysseusAttachment(kind, id);
if (!data) return;
_afterOdysseusAttachmentsAdded(1, label || data.filename);
if (!opts.keepOpen) _closeOdysseusAttachMenu();
} catch (err) {
console.error('Failed to attach Odysseus item:', err);
if (uiModule) uiModule.showError('Failed to attach from Odysseus');
}
}
function _selectedOdysseusAttachRows(menu) {
return Array.from(menu?.querySelectorAll?.('.email-odysseus-attach-row.is-selected') || []);
}
function _syncOdysseusAttachSelection(menu) {
const selected = _selectedOdysseusAttachRows(menu);
const bar = menu?.querySelector?.('.email-odysseus-attach-actions');
const count = menu?.querySelector?.('.email-odysseus-attach-count');
const attachBtn = menu?.querySelector?.('.email-odysseus-attach-selected');
if (bar) bar.style.display = '';
if (count) count.textContent = selected.length ? `${selected.length} selected` : 'Select items to attach';
if (attachBtn) attachBtn.disabled = selected.length === 0;
}
async function _attachSelectedOdysseusItems(menu) {
const rows = _selectedOdysseusAttachRows(menu);
if (!rows.length) return;
const btn = menu.querySelector('.email-odysseus-attach-selected');
if (btn) {
btn.disabled = true;
btn.classList.add('is-loading');
}
let added = 0;
try {
const items = rows.map(row => ({ kind: row.dataset.kind, id: row.dataset.id })).filter(x => x.kind && x.id);
let zip = false;
if (items.length > 5) {
const ask = window.styledConfirm || uiModule?.styledConfirm;
zip = ask
? await ask(`Attach ${items.length} files as one zip?`, { confirmText: 'Zip', cancelText: 'Separate' })
: window.confirm(`Attach ${items.length} files as one zip?`);
}
if (zip) {
await _stageOdysseusZip(items);
added = 1;
} else {
for (const item of items) {
await _stageOdysseusAttachment(item.kind, item.id);
added += 1;
}
}
_afterOdysseusAttachmentsAdded(added, zip ? 'odysseus-attachments.zip' : undefined);
_closeOdysseusAttachMenu();
} catch (err) {
console.error('Failed to attach selected Odysseus items:', err);
if (uiModule) uiModule.showError(added ? `Attached ${added}, then failed` : 'Failed to attach from Odysseus');
_renderComposeAttachments();
} finally {
if (btn) {
btn.classList.remove('is-loading');
btn.disabled = false;
}
}
}
async function _loadOdysseusAttachItems(menu, kind) {
const list = menu.querySelector('.email-odysseus-attach-list');
if (!list) return;
menu.dataset.odyAttachKind = kind;
list.replaceChildren(spinnerModule.createLoadingRow('Loading…', 14));
menu.querySelectorAll('[data-ody-attach-kind]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.odyAttachKind === kind);
});
const q = (menu.querySelector('.email-odysseus-attach-search')?.value || '').trim();
try {
const params = new URLSearchParams({ sort: 'recent', limit: '20' });
if (q) params.set('search', q);
const endpoint = kind === 'gallery'
? `${API_BASE}/api/gallery/library?${params}`
: `${API_BASE}/api/documents/library?${params}`;
const res = await fetch(endpoint, { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
const items = kind === 'gallery'
? (Array.isArray(data?.items) ? data.items : Array.isArray(data?.images) ? data.images : [])
: (Array.isArray(data?.documents) ? data.documents : Array.isArray(data?.items) ? data.items : []);
if (!items.length) {
list.innerHTML = `<div class="email-odysseus-attach-empty">${q ? 'No matches' : `No ${kind === 'gallery' ? 'images' : 'documents'}`}</div>`;
_syncOdysseusAttachSelection(menu);
return;
}
list.innerHTML = '';
for (const item of items) {
const label = _odysseusAttachLabel(item, kind);
const row = document.createElement('button');
row.type = 'button';
row.className = `email-odysseus-attach-row ${kind === 'gallery' ? 'is-gallery' : ''}`;
row.dataset.id = item.id || '';
row.dataset.kind = kind;
if (kind === 'gallery') {
const src = item.url ? `${API_BASE}${item.url}` : '';
row.innerHTML = `
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</span>
<span class="email-odysseus-attach-main">
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
<span class="email-odysseus-attach-meta">${_escHtml(item.filename || 'image')}</span>
</span>
`;
} else {
row.innerHTML = `
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
<span class="email-odysseus-attach-icon">${langIcon(item.language || 'text', 14, { style: 'opacity:0.8;' })}</span>
<span class="email-odysseus-attach-main">
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
<span class="email-odysseus-attach-meta">${_escHtml(item.language || 'text')}</span>
</span>
`;
}
row.addEventListener('click', (ev) => {
ev.preventDefault();
row.classList.toggle('is-selected');
_syncOdysseusAttachSelection(menu);
});
row.addEventListener('dblclick', () => _attachOdysseusItem(kind, item.id, label, { keepOpen: false }));
list.appendChild(row);
}
_syncOdysseusAttachSelection(menu);
} catch (err) {
console.error('Failed to load Odysseus attach items:', err);
list.innerHTML = '<div class="email-odysseus-attach-empty">Could not load</div>';
}
}
function _showComposeAttachMenu(anchor) {
if (_activeDocLanguage() !== 'email') {
document.getElementById('doc-md-image-input')?.click();
return;
}
_closeOdysseusAttachMenu();
const menu = document.createElement('div');
menu.className = 'email-odysseus-attach-menu';
menu.innerHTML = `
<button type="button" class="email-odysseus-attach-local">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Upload file
</button>
<div class="email-odysseus-attach-tabs">
<button type="button" data-ody-attach-kind="document" class="active">
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h6"/></svg>
<span>Documents</span>
</button>
<button type="button" data-ody-attach-kind="gallery">
<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>
<span>Gallery</span>
</button>
</div>
<label class="email-odysseus-attach-search-wrap">
<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"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="search" class="email-odysseus-attach-search" placeholder="Search attachments">
</label>
<div class="email-odysseus-attach-list"></div>
<div class="email-odysseus-attach-actions">
<span class="email-odysseus-attach-count"></span>
<button type="button" class="email-odysseus-attach-selected" disabled>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.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"></path></svg>
<span>Attach</span>
</button>
</div>
`;
document.body.appendChild(menu);
_odysseusAttachMenu = menu;
_positionOdysseusAttachMenu(anchor, menu);
menu.querySelector('.email-odysseus-attach-local')?.addEventListener('click', () => {
_closeOdysseusAttachMenu();
document.getElementById('doc-email-file-input')?.click();
});
menu.querySelectorAll('[data-ody-attach-kind]').forEach(btn => {
btn.addEventListener('click', () => _loadOdysseusAttachItems(menu, btn.dataset.odyAttachKind));
});
let attachSearchTimer = null;
menu.querySelector('.email-odysseus-attach-search')?.addEventListener('input', () => {
clearTimeout(attachSearchTimer);
attachSearchTimer = setTimeout(() => {
_loadOdysseusAttachItems(menu, menu.dataset.odyAttachKind || 'document');
}, 220);
});
menu.querySelector('.email-odysseus-attach-selected')?.addEventListener('click', () => _attachSelectedOdysseusItems(menu));
setTimeout(() => {
document.addEventListener('click', _attachMenuOutsideClick, true);
document.addEventListener('keydown', _attachMenuEscape, true);
}, 0);
_loadOdysseusAttachItems(menu, 'document');
}
function _isMarkdownImageFile(file) {
if (!file) return false;
if ((file.type || '').toLowerCase().startsWith('image/')) return true;
@@ -3248,13 +3599,23 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}).filter(Boolean)
);
sugg.innerHTML = '';
sugg.dataset.navStarted = '0';
let count = 0;
for (const c of data.results) {
for (const em of (c.emails || [])) {
if (already.has(em.toLowerCase())) continue;
const item = document.createElement('div');
item.className = 'contact-suggestion';
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', 'false');
item.innerHTML = `<span class="contact-name">${_escHtml(c.name)}</span><span class="contact-email">${_escHtml(em)}</span>`;
item.addEventListener('mouseenter', () => {
sugg.dataset.navStarted = '1';
sugg.querySelectorAll('.contact-suggestion').forEach(it => {
it.classList.toggle('active', it === item);
it.setAttribute('aria-selected', it === item ? 'true' : 'false');
});
});
// mousedown fires before blur so the click doesn't get lost
item.addEventListener('mousedown', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
item.addEventListener('click', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
@@ -3263,9 +3624,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
}
if (count === 0) { sugg.style.display = 'none'; return; }
// Auto-highlight first suggestion so Enter accepts it.
const first = sugg.querySelector('.contact-suggestion');
if (first) first.classList.add('active');
sugg.style.display = '';
} catch (e) {
sugg.style.display = 'none';
@@ -3291,16 +3649,32 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const items = open ? sugg.querySelectorAll('.contact-suggestion') : [];
const active = open ? sugg.querySelector('.contact-suggestion.active') : null;
let idx = active ? Array.from(items).indexOf(active) : -1;
const setActive = (nextIdx) => {
items.forEach((it, i) => {
const on = i === nextIdx;
it.classList.toggle('active', on);
it.setAttribute('aria-selected', on ? 'true' : 'false');
});
if (items[nextIdx]) {
items[nextIdx].scrollIntoView({ block: 'nearest' });
}
};
if (open && e.key === 'ArrowDown') {
e.preventDefault();
idx = Math.min(items.length - 1, idx + 1);
items.forEach(it => it.classList.remove('active'));
if (items[idx]) items[idx].classList.add('active');
if (!items.length) return;
if (sugg.dataset.navStarted !== '1') {
idx = Math.max(0, idx);
sugg.dataset.navStarted = '1';
} else {
idx = Math.min(items.length - 1, idx + 1);
}
setActive(idx);
} else if (open && e.key === 'ArrowUp') {
e.preventDefault();
if (!items.length) return;
sugg.dataset.navStarted = '1';
idx = Math.max(0, idx - 1);
items.forEach(it => it.classList.remove('active'));
if (items[idx]) items[idx].classList.add('active');
setActive(idx);
} else if (e.key === 'Enter') {
// If a suggestion is highlighted, commit it. Otherwise — if the
// current fragment already looks like a complete email — commit
@@ -3417,8 +3791,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const rawBody = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const body = _sanitizeOutgoingEmailBody(rawBody);
let bodyHtml = _rich ? _rich.innerHTML : null;
if (_rich && body !== rawBody) bodyHtml = _emailBodyToHtml(body);
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
if (!to || !body) {
@@ -3581,8 +3957,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const rawBody = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const body = _sanitizeOutgoingEmailBody(rawBody);
let bodyHtml = _rich ? _rich.innerHTML : null;
if (_rich && body !== rawBody) bodyHtml = _emailBodyToHtml(body);
const btn = document.getElementById('doc-email-draft-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
const controller = new AbortController();
@@ -3924,10 +4302,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const references = document.getElementById('doc-email-references')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const body = (_rich
const rawBody = (_rich
? (_rich.innerText || _rich.textContent || '')
: (document.getElementById('doc-editor-textarea')?.value || '')
).trim();
const body = _sanitizeOutgoingEmailBody(rawBody);
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -5146,12 +5525,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}, true);
// Attachments
document.getElementById('doc-email-attach-btn')?.addEventListener('click', () => {
document.getElementById('doc-email-file-input')?.click();
document.getElementById('doc-email-attach-btn')?.addEventListener('click', (e) => {
_showComposeAttachMenu(e.currentTarget);
});
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', () => {
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', (e) => {
if (_activeDocLanguage() === 'email') {
document.getElementById('doc-email-file-input')?.click();
_showComposeAttachMenu(e.currentTarget);
} else {
document.getElementById('doc-md-image-input')?.click();
}
@@ -10063,11 +10442,33 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (!docs.has(docId)) {
const curSession = sessionModule?.getCurrentSessionId() || '';
let reuseId = null;
const incomingFields = _parseEmailHeader(newContent || '');
// Email subjects repeat constantly ("test", "Re: ..."). Match open
// compose docs by source email identity first; never let a same-title
// draft steal an update meant for a different open email.
if (incomingFields.sourceUid) {
const wantFolder = (incomingFields.sourceFolder || 'INBOX').trim();
for (const [existingId, existingDoc] of docs) {
const existingFields = _parseEmailHeader(existingDoc.content || '');
if (
String(existingFields.sourceUid || '') === String(incomingFields.sourceUid)
&& ((existingFields.sourceFolder || 'INBOX').trim() === wantFolder)
) {
reuseId = existingId;
break;
}
}
}
// First: match by title
if (data.title) {
if (!reuseId && data.title) {
for (const [existingId, existingDoc] of docs) {
if (existingDoc.title === data.title && existingDoc.sessionId === curSession) {
if (
existingDoc.title === data.title
&& existingDoc.sessionId === curSession
&& (existingDoc.language || '').toLowerCase() !== 'email'
) {
reuseId = existingId;
break;
}
@@ -10202,8 +10603,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (isEmailUpdate) {
const updatedDocForEmail = docs.get(docId);
if (updatedDocForEmail) {
const updatedFields = _parseEmailHeader(updatedDocForEmail.content || '');
_clearEmailLocalDraft(updatedFields.sourceUid, updatedFields.sourceFolder, updatedFields.inReplyTo);
_setMarkdownPreviewActive(false, { remember: false });
_showEmailFields(updatedDocForEmail);
_showEmailFields(updatedDocForEmail, { applyLocalDraft: false });
}
} else {
if (textarea) textarea.value = newContent;
@@ -10226,7 +10629,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (isEmailUpdate && updatedDoc) {
updatedDoc.language = 'email';
if (langSelect) langSelect.value = 'email';
_showEmailFields(updatedDoc);
const updatedFields = _parseEmailHeader(updatedDoc.content || '');
_clearEmailLocalDraft(updatedFields.sourceUid, updatedFields.sourceFolder, updatedFields.inReplyTo);
_showEmailFields(updatedDoc, { applyLocalDraft: false });
}
if (updatedDoc && !updatedDoc.userSetLanguage && !updatedDoc.language) {
setTimeout(attemptAutoDetect, 100);
+89 -93
View File
@@ -8,7 +8,7 @@ import sessionModule from './sessions.js';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js';
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -29,6 +29,23 @@ const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replySeparator = '---------- Previous message ----------';
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
function _splitEmailAddresses(raw) {
return (typeof raw === 'string' ? raw : '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
}
function _isMyEmailAddress(addr, myAddresses) {
const email = extractEmail(addr);
if (!email) return false;
return new Set((myAddresses || []).map(a => String(a || '').trim().toLowerCase()).filter(Boolean)).has(email);
}
function _withoutMyAddresses(raw, myAddresses) {
return _splitEmailAddresses(raw).filter(addr => !_isMyEmailAddress(addr, myAddresses));
}
function _openCalendarEventFromEmail(uid) {
const target = String(uid || '').trim();
if (!target) return;
@@ -832,13 +849,26 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
? window._myEmailAddresses
: (window._myEmailAddress ? [window._myEmailAddress] : []);
let toAddress = data.from_address;
const fromIsMe = _isMyEmailAddress(data.from_address, myAddresses);
const originalToWithoutMe = _withoutMyAddresses(data.to, myAddresses);
const originalCcWithoutMe = _withoutMyAddresses(data.cc, myAddresses);
let toAddress = fromIsMe
? (originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address)
: data.from_address;
let ccAddresses = '';
let subjectPrefix = 'Re: ';
if (mode === 'reply-all') {
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
ccAddresses = buildReplyAllCc(data, myAddresses);
if (fromIsMe) {
// Replying from Sent should go back to the people I originally wrote
// to, not to myself. Keep original Cc recipients on Cc.
toAddress = originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address;
ccAddresses = originalCcWithoutMe.filter(addr => !originalToWithoutMe.some(t => extractEmail(t) === extractEmail(addr))).join(', ');
} else {
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
ccAddresses = buildReplyAllCc(data, myAddresses);
}
} else if (mode === 'forward') {
toAddress = '';
subjectPrefix = 'Fwd: ';
@@ -921,7 +951,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
// 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 === 'view' || mode === 'open' || (!!aiSuggestedBody && mode !== 'forward'));
const reuseExisting = mode !== 'forward';
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
? _docModule.findEmailDocId(em.uid, _currentFolder)
: null;
@@ -930,52 +960,22 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
await _docModule.loadDocument(existingDocId);
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody);
_bringEmailReplyDraftToFrontOnMobile();
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: true });
}
_bringEmailReplyDraftToFrontOnMobile();
} else {
// If the user already has a chat session open, reuse it instead of
// spawning a new one. They asked for this explicitly — opening reply
// mid-conversation shouldn't whip them out of context.
let activeSid = '';
try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
const activeSid = await _createEmailChat(data);
if (!activeSid) {
// No chat in flight — keep the old behavior of creating a scoped
// email-thread chat, then RE-READ the now-current session id. The
// POST below requires a session_id (backend 400s without one), and
// the freshly-created chat is what should own the reply draft.
await _createEmailChat(data);
try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
}
// Guarantee a session — _createEmailChat can't make one when there's
// no enabled default-chat endpoint, which left the reply POSTing a
// null session_id → 400. Create a bare session so the draft always
// has a home regardless of chat/endpoint config.
if (!activeSid) {
try {
const _fd = new FormData();
_fd.append('name', `Email: ${(data.subject || '').slice(0, 60)}`);
_fd.append('skip_validation', 'true');
const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
if (_sres.ok) {
const _sdata = await _sres.json();
if (_sdata && _sdata.id) {
activeSid = _sdata.id;
if (sessionModule?.loadSessions) await sessionModule.loadSessions();
if (sessionModule?.selectSession) await sessionModule.selectSession(activeSid);
}
}
} catch (e) { console.error('reply: bare session create failed', e); }
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Reuse the user's current chat session if there is one (so the
// reply draft lives in the chat they were just in); otherwise
// null and the new email-chat (created above) takes over.
session_id: activeSid || null,
session_id: activeSid,
title: data.subject,
content: content,
language: 'email',
@@ -1256,31 +1256,58 @@ async function _toggleDone(em, itemEl) {
}
async function _createEmailChat(emailData) {
const subject = String(emailData?.subject || 'New Email').trim() || 'New Email';
const title = subject === 'New Email' ? 'New Email' : `Email: ${subject.slice(0, 60)}`;
try {
// Try current session's endpoint first
const current = sessionModule.getSessions?.().find(s => s.id === sessionModule.getCurrentSessionId?.());
let url, model, endpointId;
if (current && current.endpoint_url && current.model) {
url = current.endpoint_url;
model = current.model;
endpointId = current.endpoint_id;
} else {
// Fall back to default chat config
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
const dc = await dcRes.json();
url = dc.endpoint_url;
model = dc.model;
endpointId = dc.endpoint_id;
const currentSid = sessionModule.getCurrentSessionId?.() || '';
const current = sessionModule.getSessions?.().find(s => s.id === currentSid);
const currentIsBlank = !!current
&& !current.archived
&& !current.has_documents
&& !current.has_images
&& Number(current.message_count || 0) === 0
&& current.folder !== 'Assistant'
&& current.folder !== 'Tasks';
if (currentIsBlank) {
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = title;
return current.id;
}
let url = current?.endpoint_url || '';
let model = current?.model || '';
let endpointId = current?.endpoint_id || '';
if (!url || !model) {
try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
const dc = dcRes.ok ? await dcRes.json() : {};
url = dc.endpoint_url || '';
model = dc.model || '';
endpointId = dc.endpoint_id || '';
} catch (_) {}
}
if (url && model) {
await sessionModule.createDirectChat(url, model, endpointId);
// Set a helpful title in the chat meta
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = `Email: ${(emailData.subject || '').slice(0, 60)}`;
const fd = new FormData();
fd.append('name', title);
fd.append('skip_validation', 'true');
if (url) fd.append('endpoint_url', url);
if (model) fd.append('model', model);
if (endpointId) fd.append('endpoint_id', endpointId);
const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd, credentials: 'same-origin' });
if (!res.ok) {
console.error('email chat create failed', res.status, await res.text().catch(() => ''));
return '';
}
const payload = await res.json().catch(() => ({}));
const sid = payload?.id || '';
if (!sid) return '';
if (sessionModule?.loadSessions) await sessionModule.loadSessions();
if (sessionModule?.selectSession) await sessionModule.selectSession(sid);
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = title;
return sid;
} catch (e) {
console.error('Failed to create email chat:', e);
return '';
}
}
@@ -1292,38 +1319,7 @@ async function _composeNew() {
// (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
// after the session + doc exist.
try {
// /api/document requires a session_id (returns 400 if null), so reuse
// the active chat if there is one — otherwise spin up an email-scoped
// chat first, same pattern the reply path uses.
let sid = '';
try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
if (!sid) {
await _createEmailChat({ subject: 'New Email' });
try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
}
// Guarantee a session — _createEmailChat can't make one when there's no
// enabled default-chat endpoint, which left compose POSTing a null
// session_id → 400 (the draft silently never appeared). Same bare-session
// fallback the reply flow uses.
if (!sid) {
try {
const _fd = new FormData();
_fd.append('name', 'New Email');
_fd.append('skip_validation', 'true');
const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
if (_sres.ok) {
const _sdata = await _sres.json();
if (_sdata && _sdata.id) {
sid = _sdata.id;
// NOTE: intentionally do NOT loadSessions()/selectSession() here.
// Re-selecting the (empty) session re-renders the chat and flashes
// the welcome splash for a frame before the draft opens — the
// "splash flickers like crazy then email opens" bug. The doc only
// needs the session_id; the draft opens in the doc panel regardless.
}
}
} catch (e) { console.error('compose: bare session create failed', e); }
}
const 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(() => {});
+282 -45
View File
@@ -35,6 +35,8 @@ let _libSearchSeq = 0;
let _libSearchHadResults = false;
let _libSearchInFlight = false;
let _activeEmailReaderForSelectAll = null;
let _libAccountsLoadedAt = 0;
const _LIB_ACCOUNTS_TTL_MS = 5 * 60 * 1000;
function _isEmailTypingTarget(t) {
return !!(t && (
@@ -981,6 +983,28 @@ function _loadEmailsFresh() {
return _loadEmails({ force: true, useCache: false });
}
function _isChatInteractionBusy() {
try {
if (window.__odysseusChatBusy) return true;
const until = Number(window.__odysseusChatBusyUntil || 0);
return until > Date.now();
} catch (_) {
return false;
}
}
function _loadEmailsWhenChatIdle({ delay = 700, retries = 180, options = {} } = {}) {
const run = () => {
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
if (_isChatInteractionBusy() && retries > 0) {
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
return;
}
_loadEmails(options);
};
setTimeout(run, Math.max(0, Number(delay) || 0));
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return;
const elapsed = Date.now() - _libLastPrewarmAt;
@@ -1011,7 +1035,10 @@ async function _prewarmEmailViews() {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) state._libAccounts = accountsData.accounts;
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
}
} catch (_) {}
@@ -1737,7 +1764,13 @@ export function openEmailLibrary(opts = {}) {
};
document.addEventListener('keydown', state._libEscHandler, true);
_renderAccountsLoading();
const grid = document.getElementById('email-lib-grid');
if (grid && !grid.children.length) _renderEmailLoading(grid);
if (Array.isArray(state._libAccounts) && state._libAccounts.length) {
_renderAccountsStrip();
} else {
_renderAccountsLoading();
}
// Await accounts before loading emails so the list request carries the
// right account_id from the very first fetch (now that we auto-select
// an explicit account instead of relying on a 'Default' chip).
@@ -1745,17 +1778,31 @@ export function openEmailLibrary(opts = {}) {
await _loadAccounts();
_loadFolders();
_loadEmailReminderBellVisibility();
_loadEmails();
_loadEmailsWhenChatIdle();
})();
}
async function _loadAccounts() {
async function _loadAccounts({ force = false } = {}) {
const hasCachedAccounts = Array.isArray(state._libAccounts) && state._libAccounts.length;
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
if (!force && hasCachedAccounts && accountsFresh) {
if (!state._libAccountId) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def?.id || null;
_publishActiveAccount();
}
_renderAccountsStrip();
return;
}
try {
const r = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
state._libAccounts = d.accounts || [];
} catch (_) { state._libAccounts = []; }
_libAccountsLoadedAt = Date.now();
} catch (_) {
if (!hasCachedAccounts) state._libAccounts = [];
}
// The 'Default' chip is gone — pick an explicit account so the email
// list and any per-email actions (open in new tab, mark read, etc.)
// always carry an account_id and can't desync from the server's
@@ -2080,6 +2127,60 @@ function _crossFolderCandidates() {
return Array.from(new Set(candidates.filter(Boolean)));
}
function _findEmailFolder(patterns, fallback) {
const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : [];
const lower = new Map(available.map(f => [String(f).toLowerCase(), f]));
for (const p of patterns) {
const direct = lower.get(String(p).toLowerCase());
if (direct) return direct;
}
return available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))) || fallback;
}
function _sentFolderName() {
return _findEmailFolder(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], 'Sent');
}
function _deriveSearchScope(rawQuery) {
const original = String(rawQuery || '').trim();
const tokens = original.split(/\s+/).filter(Boolean);
let scope = 'all';
const kept = [];
let forced = '';
for (const token of tokens) {
const t = token.toLowerCase().replace(/^#+/, '').replace(/:$/, '');
if (['sent', 'sentmail', 'sent-mail', 'outbox'].includes(t)) {
forced = 'sent';
continue;
}
if (['inbox'].includes(t)) {
forced = 'inbox';
continue;
}
kept.push(token);
}
if (forced) scope = forced;
let folder = 'INBOX';
let serverScope = 'all';
if (scope === 'sent') {
folder = _sentFolderName();
serverScope = 'folder';
} else if (scope === 'inbox') {
folder = 'INBOX';
serverScope = 'folder';
} else if (scope === 'current') {
folder = state._libFolder || 'INBOX';
serverScope = 'folder';
}
return {
scope,
folder,
serverScope,
q: forced ? kept.join(' ').trim() : original,
forced,
};
}
// Snapshot of state._libEmails taken right before search starts so we
// can both filter locally and restore on clear without re-fetching.
let _libPreSearchEmails = null;
@@ -2429,6 +2530,15 @@ function _addSearchPill(pill) {
_applyPillFilter();
}
function _searchQueryFromPills() {
const parts = [];
for (const p of state._libSearchPills || []) {
if (p.type === 'text' && p.text) parts.push(String(p.text).trim());
else if (p.type === 'contact' && (p.email || p.name)) parts.push(String(p.email || p.name).trim());
}
return parts.filter(Boolean).join(' ').trim();
}
function _removeSearchPillAt(idx) {
if (!Array.isArray(state._libSearchPills)) return;
const removed = state._libSearchPills[idx];
@@ -2455,6 +2565,26 @@ function _removeSearchPillAt(idx) {
_loadEmails({ useCache: true });
return;
}
const remainingQuery = _searchQueryFromPills();
if (remainingQuery.length >= 2) {
state._libSearch = remainingQuery;
const _searchInput = document.getElementById('email-lib-search');
if (_searchInput) _searchInput.value = '';
state._libSearchDraft = '';
_doSearch();
return;
}
if ((state._libSearchPills || []).length && _libSearchHadResults) {
_libSearchHadResults = false;
_libPreSearchEmails = null;
_libPreSearchTotal = 0;
_libServerSearchEmails = null;
_libServerSearchTotal = 0;
state._libSearch = '';
state._libOffset = 0;
_loadEmails({ useCache: true });
return;
}
_applyPillFilter();
}
@@ -2724,8 +2854,9 @@ window.addEventListener('click', (e) => {
async function _doSearch() {
_exitEmailReaderModeForList();
const seq = ++_libSearchSeq;
const q = state._libSearch.trim();
if (q.length < 2) {
const derived = _deriveSearchScope(state._libSearch);
const q = derived.q;
if (q.length < 2 && !derived.forced) {
// Empty or too short — restore the normal folder if a previous search
// had replaced the grid contents.
if (_libSearchHadResults) {
@@ -2738,7 +2869,8 @@ async function _doSearch() {
return;
}
const accountAtStart = state._libAccountId || '';
const folderAtStart = state._libFolder || 'INBOX';
const folderAtStart = derived.folder || state._libFolder || 'INBOX';
const serverScopeAtStart = derived.serverScope || 'all';
// No grid-blanking spinner — the local filter already painted something
// useful. Surface progress in the stats badge instead so the user knows
// the server search is still grinding.
@@ -2753,25 +2885,67 @@ async function _doSearch() {
const stillCurrent = () => (
seq === _libSearchSeq &&
q === state._libSearch.trim() &&
q === _deriveSearchScope(state._libSearch).q &&
accountAtStart === (state._libAccountId || '') &&
folderAtStart === (state._libFolder || 'INBOX')
folderAtStart === (_deriveSearchScope(state._libSearch).folder || state._libFolder || 'INBOX')
);
const searchUrl = (localOnly = false) => {
const params = new URLSearchParams({
folder: folderAtStart,
q,
limit: '100',
scope: serverScopeAtStart,
});
if (accountAtStart) params.set('account_id', accountAtStart);
if (localOnly) params.set('local_only', '1');
return `${API_BASE}/api/email/search?${params.toString()}`;
};
const folderListUrl = () => {
const params = new URLSearchParams({
folder: folderAtStart,
limit: '100',
offset: '0',
filter: state._libFilter || 'all',
});
if (accountAtStart) params.set('account_id', accountAtStart);
return `${API_BASE}/api/email/list?${params.toString()}`;
};
const mergeSearchResults = (painted, incoming) => {
const byKey = new Map();
const out = [];
const add = (em) => {
if (!em) return;
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
if (byKey.has(key)) return;
byKey.set(key, em);
out.push(em);
};
(painted || []).forEach(add);
const additions = [];
const addIncoming = (em) => {
if (!em) return;
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
if (byKey.has(key)) return;
byKey.set(key, em);
additions.push(em);
};
(incoming || []).forEach(addIncoming);
additions.sort((a, b) => {
const ad = Number(a?.date_epoch || 0);
const bd = Number(b?.date_epoch || 0);
if (bd !== ad) return bd - ad;
return String(b?.date || '').localeCompare(String(a?.date || ''));
});
return out.concat(additions);
};
let paintedInterimResults = false;
const paintSearchData = (data, interim = false) => {
if (!stillCurrent()) return false;
if (data.error) throw new Error(data.error);
const results = data.emails || [];
let results = data.emails || [];
if (!interim && paintedInterimResults) {
results = mergeSearchResults(state._libEmails || [], results);
}
if (!interim && paintedInterimResults && results.length === 0) {
if (stats) {
const count = state._libTotal || (state._libEmails || []).length;
@@ -2789,7 +2963,7 @@ async function _doSearch() {
const preservingBase = !!(_libServerSearchEmails && pills.length > 1);
if (!preservingBase) {
_libServerSearchEmails = results.slice();
_libServerSearchTotal = data.total || results.length;
_libServerSearchTotal = Math.max(Number(data.total || 0), results.length);
_libPreSearchEmails = results.slice();
_libPreSearchTotal = _libServerSearchTotal;
state._libEmails = results;
@@ -2803,7 +2977,7 @@ async function _doSearch() {
if (!(state._libEmails || []).length && !preservingBase) state._libEmails = results;
}
_renderGrid();
const count = data.total || results.length;
const count = Math.max(Number(data.total || 0), results.length);
if (stats) {
if (interim) {
stats.textContent = `${count} cached match${count === 1 ? '' : 'es'} · searching…`;
@@ -2822,9 +2996,22 @@ async function _doSearch() {
};
try {
if (q.length < 2 && derived.forced) {
const res = await fetch(folderListUrl());
const data = await res.json();
if (!stillCurrent()) return;
paintSearchData({
emails: (data.emails || []).map(em => ({ ...em, folder: folderAtStart })),
total: data.total || (data.emails || []).length,
source: 'folder',
sync: { source: 'folder' },
}, false);
return;
}
const fullSearchPromise = fetch(searchUrl(false)).then(res => res.json());
const localSearchPromise = fetch(searchUrl(true)).then(res => res.json());
try {
const localRes = await fetch(searchUrl(true));
const localData = await localRes.json();
const localData = await localSearchPromise;
if (!stillCurrent()) return;
if (!localData.error && (localData.emails || []).length) {
paintSearchData(localData, true);
@@ -2833,8 +3020,7 @@ async function _doSearch() {
if (!stillCurrent()) return;
}
const res = await fetch(searchUrl(false));
const data = await res.json();
const data = await fullSearchPromise;
if (!stillCurrent()) return;
paintSearchData(data, false);
} catch (e) {
@@ -3401,9 +3587,12 @@ function _createCard(em) {
card.appendChild(cb);
}
// In Sent folder, show the recipient(s) — the sender is always you and
// hides the actually useful info. Outside Sent, show the sender as before.
const isSentFolderEarly = /sent/i.test(state._libFolder);
// In Sent results, show the recipient(s) — the sender is always you and
// hides the actually useful info. Search results can be stamped with their
// real folder while the visible folder selector still says INBOX, so use the
// email's folder first.
const cardFolder = em.folder || state._libFolder || 'INBOX';
const isSentFolderEarly = /sent/i.test(cardFolder);
let senderName;
let senderAddress;
if (isSentFolderEarly) {
@@ -3482,7 +3671,7 @@ function _createCard(em) {
}
// Done check + unread dot stay next to the subject on the left.
const isSentFolder = /sent/i.test(state._libFolder);
const isSentFolder = /sent/i.test(cardFolder);
if (!isSentFolder) {
const doneCheck = document.createElement('span');
doneCheck.className = 'email-card-done' + (em.is_answered ? ' active' : '');
@@ -3512,10 +3701,10 @@ function _createCard(em) {
}
try {
if (newState) {
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
}
} catch (err) { console.error(err); }
};
@@ -3571,8 +3760,14 @@ function _createCard(em) {
const meta = document.createElement('div');
meta.className = 'memory-item-meta';
meta.style.cssText = 'font-size:10px;opacity:0.7;margin-top:2px;';
const showFolderChip = !!(_libSearchHadResults && cardFolder);
const prettyFolder = folderDisplayName(cardFolder);
const sentChip = isSentFolderEarly ? '<span class="email-sent-chip" title="Sent email">Sent</span>' : '';
const folderChip = showFolderChip && !isSentFolderEarly
? `<span class="email-folder-chip" title="${_esc(cardFolder)}">${_esc(prettyFolder)}</span>`
: '';
const senderPrefix = isSentFolderEarly ? 'to ' : '';
meta.innerHTML = `<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
meta.innerHTML = `${sentChip}${folderChip}<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
content.appendChild(meta);
card.appendChild(content);
@@ -5284,6 +5479,63 @@ function _wireAttachmentHandlers(reader, folder) {
// a ReferenceError when this fn is called from contexts that don't have
// _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
reader.querySelectorAll('.email-attachments-download-all').forEach(btn => {
if (btn.dataset.wired === '1') return;
btn.dataset.wired = '1';
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (btn.dataset.downloading === '1') return;
const uid = btn.dataset.attUid;
const sourceFolder = btn.dataset.attFolder || useFolder;
const count = Number(btn.dataset.attCount || 0);
if (!uid) return;
const originalHtml = btn.innerHTML;
const originalTitle = btn.title;
btn.dataset.downloading = '1';
btn.classList.add('is-loading');
try {
const sp = window.spinnerModule || (await import('./spinner.js')).default;
const wp = sp.createWhirlpool(12);
wp.element.style.margin = '0';
btn.textContent = '';
btn.appendChild(wp.element);
const label = document.createElement('span');
label.textContent = 'All';
btn.appendChild(label);
} catch (_) {
btn.textContent = 'All...';
}
try {
const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) {
const msg = await res.text().catch(() => '');
console.error('attachments zip download failed', res.status, msg);
location.href = url;
return;
}
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = `email-${uid}-attachments.zip`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
} catch (e) {
console.error('attachments zip download error', e);
try { const { showError } = await import('./ui.js'); showError('Could not download attachments'); } catch (_) {}
} finally {
delete btn.dataset.downloading;
btn.classList.remove('is-loading');
btn.title = originalTitle;
btn.innerHTML = originalHtml;
}
});
});
reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
if (openBtn.dataset.wired === '1') return;
openBtn.dataset.wired = '1';
@@ -5487,11 +5739,15 @@ function _buildAttsHtmlFor(uid, data) {
? `Thread attachments (${related.length})`
: `Hidden inline attachments (${hidden.length})`;
const startCollapsed = !visible.length && !related.length;
const downloadAllBtn = visible.length > 4
? `<button type="button" class="email-attachments-download-all" title="Download all attachments" data-att-uid="${_esc(uid)}" data-att-folder="${_esc(data.folder || state._libFolder || 'INBOX')}" data-att-count="${visible.length}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>All</span></button>`
: '';
return (
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
+ '<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="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>'
+ `<span>${label}</span>`
+ downloadAllBtn
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ visibleSection
@@ -6350,26 +6606,7 @@ async function _translateEmail(reader, language, opts = {}) {
}
async function _maybeAutoTranslateEmail(reader) {
if (!reader || reader.dataset.autoTranslateChecked === '1') return;
reader.dataset.autoTranslateChecked = '1';
try {
const res = await fetch(`${API_BASE}/api/email/config`);
const cfg = await res.json();
if (!cfg || !cfg.email_auto_translate) return;
try {
const sid = window.sessionModule?.getCurrentSessionId?.() || '';
if (sid) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 800);
const statusRes = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, {
signal: ctrl.signal,
}).catch(() => null);
clearTimeout(timer);
if (statusRes && statusRes.ok) return;
}
} catch (_) {}
await _translateEmail(reader, cfg.email_translate_language || 'English', { auto: true });
} catch (_) {}
if (reader) reader.dataset.autoTranslateChecked = '1';
}
// Keep an email ⋮ dropdown inside the viewport: when it would spill past the
+146 -2
View File
@@ -24,6 +24,131 @@ const MAX_FILES = 10;
const MAX_VISIBLE = 3;
let _expanded = false;
function _isMobileViewport() {
return window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
}
function _isCroppableImage(f) {
const mime = (f?.type || '').toLowerCase();
const name = (f?.name || '').toLowerCase();
if (!(mime.startsWith('image/') || /\.(png|jpe?g|webp|bmp)$/i.test(name))) return false;
return !mime.includes('svg') && !mime.includes('gif') && !/\.svg|\.gif$/i.test(name);
}
function _loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
function _canvasToBlob(canvas, type, quality) {
return new Promise((resolve) => canvas.toBlob(resolve, type || 'image/png', quality));
}
async function _openMobileCropper(file) {
const url = _getPreviewUrl(file);
const imgProbe = await _loadImage(url);
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'attach-crop-overlay';
overlay.innerHTML = `
<div class="attach-crop-panel" role="dialog" aria-modal="true" aria-label="Crop image">
<div class="attach-crop-stage">
<img class="attach-crop-img" alt="">
<div class="attach-crop-box"><span class="attach-crop-handle"></span></div>
</div>
<div class="attach-crop-actions">
<button type="button" class="attach-crop-btn" data-action="cancel">Cancel</button>
<button type="button" class="attach-crop-btn" data-action="original">Original</button>
<button type="button" class="attach-crop-btn attach-crop-primary" data-action="crop">Use crop</button>
</div>
</div>`;
document.body.appendChild(overlay);
const img = overlay.querySelector('.attach-crop-img');
const box = overlay.querySelector('.attach-crop-box');
img.src = url;
img.alt = file.name || 'image';
let crop = { x: 0.08, y: 0.08, w: 0.84, h: 0.84 };
let drag = null;
function applyCrop() {
const r = img.getBoundingClientRect();
const pr = overlay.querySelector('.attach-crop-stage').getBoundingClientRect();
box.style.left = (r.left - pr.left + crop.x * r.width) + 'px';
box.style.top = (r.top - pr.top + crop.y * r.height) + 'px';
box.style.width = (crop.w * r.width) + 'px';
box.style.height = (crop.h * r.height) + 'px';
}
function clampCrop() {
crop.w = Math.max(0.12, Math.min(1, crop.w));
crop.h = Math.max(0.12, Math.min(1, crop.h));
crop.x = Math.max(0, Math.min(1 - crop.w, crop.x));
crop.y = Math.max(0, Math.min(1 - crop.h, crop.y));
}
function finish(value) {
overlay.remove();
window.removeEventListener('resize', applyCrop);
resolve(value);
}
requestAnimationFrame(applyCrop);
img.addEventListener('load', applyCrop);
window.addEventListener('resize', applyCrop);
box.addEventListener('pointerdown', (e) => {
e.preventDefault();
box.setPointerCapture(e.pointerId);
drag = {
mode: e.target.classList.contains('attach-crop-handle') ? 'resize' : 'move',
sx: e.clientX,
sy: e.clientY,
start: { ...crop },
};
});
box.addEventListener('pointermove', (e) => {
if (!drag) return;
const r = img.getBoundingClientRect();
const dx = (e.clientX - drag.sx) / Math.max(1, r.width);
const dy = (e.clientY - drag.sy) / Math.max(1, r.height);
if (drag.mode === 'resize') {
crop.w = drag.start.w + dx;
crop.h = drag.start.h + dy;
} else {
crop.x = drag.start.x + dx;
crop.y = drag.start.y + dy;
}
clampCrop();
applyCrop();
});
box.addEventListener('pointerup', () => { drag = null; });
box.addEventListener('pointercancel', () => { drag = null; });
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => finish(null));
overlay.querySelector('[data-action="original"]').addEventListener('click', () => finish(file));
overlay.querySelector('[data-action="crop"]').addEventListener('click', async () => {
clampCrop();
const canvas = document.createElement('canvas');
const sx = Math.round(crop.x * imgProbe.naturalWidth);
const sy = Math.round(crop.y * imgProbe.naturalHeight);
const sw = Math.max(1, Math.round(crop.w * imgProbe.naturalWidth));
const sh = Math.max(1, Math.round(crop.h * imgProbe.naturalHeight));
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgProbe, sx, sy, sw, sh, 0, 0, sw, sh);
const type = file.type && file.type !== 'image/bmp' ? file.type : 'image/png';
const blob = await _canvasToBlob(canvas, type, 0.92);
if (!blob) { finish(file); return; }
const ext = type.includes('jpeg') ? 'jpg' : (type.split('/')[1] || 'png');
const base = (file.name || 'image').replace(/\.[^.]+$/, '');
finish(new File([blob], `${base}-cropped.${ext}`, { type, lastModified: Date.now() }));
});
});
}
function _getPreviewUrl(f) {
if (!f) return '';
let url = _previewUrls.get(f);
@@ -231,17 +356,35 @@ export async function uploadPending(opts = {}) {
/**
* Add files to pending list (capped at MAX_FILES)
*/
export function addFiles(files) {
export async function addFiles(files) {
for (const f of files) {
if (pendingFiles.length >= MAX_FILES) {
_showToast(`Max ${MAX_FILES} files allowed`);
break;
}
pendingFiles.push(f);
let nextFile = f;
if (_isMobileViewport() && _isCroppableImage(f)) {
try {
nextFile = await _openMobileCropper(f);
} catch (_) {
nextFile = f;
}
if (!nextFile) continue;
}
pendingFiles.push(nextFile);
}
renderAttachStrip();
}
export async function cropForMobileUpload(file) {
if (!_isMobileViewport() || !_isCroppableImage(file)) return file;
try {
return await _openMobileCropper(file);
} catch (_) {
return file;
}
}
function _showToast(msg) {
if (window.showToast) { window.showToast(msg); return; }
// Fallback inline toast
@@ -326,6 +469,7 @@ const fileHandlerModule = {
removePending,
uploadPending,
addFiles,
cropForMobileUpload,
getPendingCount,
getPendingInfo,
getPendingRaw,
+72 -3
View File
@@ -42,7 +42,7 @@ let _activeModel = null;
let _activeAlbum = null;
let _galleryCascaded = false; // play the domino-in cascade once per open
let _favoritesOnly = false;
let _sort = 'shuffle';
let _sort = 'recent';
let _shuffleSeed = Math.floor(Math.random() * 2 ** 31);
let _offset = 0;
// Page size — computed from the grid's visible area so taller / wider
@@ -1152,8 +1152,14 @@ function _wireUploadTile() {
input.type = 'file';
input.accept = 'image/*,video/*';
input.multiple = true;
input.addEventListener('change', () => {
if (input.files.length) _bulkUpload([...input.files], _activeAlbum);
input.addEventListener('change', async () => {
if (!input.files.length) return;
const files = [];
for (const file of [...input.files]) {
const nextFile = await fileHandlerModule.cropForMobileUpload(file);
if (nextFile) files.push(nextFile);
}
if (files.length) _bulkUpload(files, _activeAlbum);
});
input.click();
});
@@ -2810,6 +2816,68 @@ export function openGallery() {
searchInput.focus();
}
function _showImagesTab() {
const modal = document.getElementById('gallery-modal');
if (!modal) return;
modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
modal.querySelector('.gallery-tab[data-tab="images"]')?.classList.add('active');
const imagesContainer = document.getElementById('gallery-images-container');
const albumsContainer = document.getElementById('gallery-albums-container');
const editorContainer = document.getElementById('gallery-editor-container');
const settingsContainer = document.getElementById('gallery-settings-container');
if (imagesContainer) imagesContainer.style.display = '';
if (albumsContainer) albumsContainer.style.display = 'none';
if (editorContainer) editorContainer.style.display = 'none';
if (settingsContainer) settingsContainer.style.display = 'none';
}
export async function openGalleryImage(imageId) {
if (!imageId) {
openGallery();
return;
}
openGallery();
_showImagesTab();
_search = '';
_activeTags = [];
_activeModel = null;
_activeAlbum = null;
_favoritesOnly = false;
_sort = 'recent';
const searchInput = document.getElementById('gallery-search');
if (searchInput) searchInput.value = '';
const sortSel = document.getElementById('gallery-sort');
if (sortSel) sortSel.value = 'recent';
const detail = document.getElementById('gallery-detail');
if (detail) detail.style.display = 'none';
try {
await _fetchLibrary(false);
let img = _items.find(i => String(i.id) === String(imageId));
if (!img) {
const res = await fetch(`${API_BASE}/api/gallery/${encodeURIComponent(imageId)}`, { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
img = data.image || data;
}
}
if (!img || !img.id) {
uiModule.showToast?.('Photo not found in gallery', 3000);
return;
}
_openDetail(img);
const card = document.querySelector(`.gallery-card[data-id="${CSS.escape(String(imageId))}"]`);
if (card) {
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
card.classList.add('gallery-card-focus');
setTimeout(() => card.classList.remove('gallery-card-focus'), 1600);
}
} catch (err) {
console.error('[gallery] open image failed', err);
uiModule.showToast?.('Could not open photo in gallery', 3000);
}
}
function _doCloseGallery() {
const editorMounted = !!document.querySelector('#gallery-editor-container .gallery-editor');
if ((window.__galleryEditLive || isEditorOpen() || editorMounted) && !window.__galleryAllowCloseEditor) {
@@ -2882,6 +2950,7 @@ function _humanSize(bytes) {
const galleryModule = {
openGallery,
openGalleryImage,
closeGallery,
isGalleryOpen,
};
+130
View File
@@ -265,6 +265,134 @@ const _applyImageTool = createApplyImageTool({
uiModule,
});
function _setAiCommandStatus(text, kind = '') {
const el = document.getElementById('ge-ai-command-status');
if (!el) return;
el.textContent = text || '';
el.dataset.kind = kind || '';
}
function _clickToolButton(toolId) {
const btn = state.container?.querySelector(`.ge-tool-btn[data-tool="${toolId}"]`);
if (btn) btn.click();
}
function _runExistingButton(id, status) {
const btn = document.getElementById(id);
if (!btn) {
_setAiCommandStatus('That edit is not available in this editor state.', 'error');
return false;
}
if (status) _setAiCommandStatus(status, 'running');
btn.click();
return true;
}
function _buildAiCommandBox() {
const wrap = document.createElement('div');
wrap.className = 'ge-ai-command ge-ai-command-collapsed';
wrap.id = 'ge-ai-command';
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>
</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" />
<button type="submit" class="ge-ai-command-run" id="ge-ai-command-run" title="Run AI edit" aria-label="Run AI edit">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="19" x2="12" y2="5"></line>
<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>
</svg>
</button>
</form>
<div class="ge-ai-command-status" id="ge-ai-command-status" aria-live="polite"></div>
`;
return wrap;
}
function _wireAiCommandBox() {
const wrap = document.getElementById('ge-ai-command');
const toggle = document.getElementById('ge-ai-command-toggle');
const closeBtn = document.getElementById('ge-ai-command-close');
const form = document.getElementById('ge-ai-command-form');
const input = document.getElementById('ge-ai-command-input');
const runBtn = document.getElementById('ge-ai-command-run');
if (!wrap || !form || !input || !runBtn) return;
wrap.addEventListener('pointerdown', (e) => e.stopPropagation());
wrap.addEventListener('click', (e) => e.stopPropagation());
const setOpen = (open) => {
wrap.classList.toggle('ge-ai-command-collapsed', !open);
toggle?.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) requestAnimationFrame(() => input.focus());
};
toggle?.addEventListener('click', () => setOpen(wrap.classList.contains('ge-ai-command-collapsed')));
closeBtn?.addEventListener('click', () => setOpen(false));
form.addEventListener('submit', async (e) => {
e.preventDefault();
const prompt = input.value.trim();
if (!prompt) {
_setAiCommandStatus('Type what you want changed.', 'error');
input.focus();
return;
}
const p = prompt.toLowerCase();
try {
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...');
return;
}
if (/\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
_clickToolButton('upscale');
_runExistingButton('ge-upscale-ai', 'Upscaling image...');
return;
}
if (/\b(denoise|noise|grain|grainy|clean\s*up)\b/.test(p)) {
_setAiCommandStatus('Denoising image...', 'running');
await _applyImageTool('/api/image/denoise', { strength: 0.55 }, 'Denoised', runBtn, { busyLabel: 'Denoising...' });
_setAiCommandStatus('Added denoised layer.', 'done');
return;
}
if (/\b(face|portrait|skin|selfie|restore)\b/.test(p)) {
_setAiCommandStatus('Enhancing face/portrait...', 'running');
await _applyImageTool('/api/image/enhance-face', {}, 'Enhanced Face', runBtn, { busyLabel: 'Enhancing...' });
_setAiCommandStatus('Added enhanced layer.', 'done');
return;
}
if (/\b(sharpen|sharp|crisp|clearer|make it look better|enhance|improve|better)\b/.test(p)) {
const amount = document.getElementById('ge-sharpen-amount');
if (amount) {
amount.value = '65';
amount.dispatchEvent(new Event('input', { bubbles: true }));
}
_clickToolButton('sharpen');
_runExistingButton('ge-sharpen-run', 'Sharpening image...');
return;
}
const stylePrompt = document.getElementById('ge-style-prompt');
const styleStrength = document.getElementById('ge-style-strength');
if (stylePrompt) stylePrompt.value = prompt;
if (styleStrength) {
styleStrength.value = /\b(subtle|slight|small)\b/.test(p) ? '35' : '55';
styleStrength.dispatchEvent(new Event('input', { bubbles: true }));
}
_clickToolButton('style');
_runExistingButton('ge-style-run', 'Running full-image AI edit...');
} catch (err) {
console.error('[ge-ai-command] failed', err);
_setAiCommandStatus(err?.message || 'AI edit failed', 'error');
}
});
}
// Layer offsets for move tool
// ── Layer class ──
@@ -2551,6 +2679,7 @@ function _buildEditor(container) {
state.transformOverlay.className = 'ge-transform-overlay';
state.transformOverlayCtx = state.transformOverlay.getContext('2d');
canvasArea.appendChild(state.transformOverlay);
canvasArea.appendChild(_buildAiCommandBox());
// Keep the transform handles glued to the photo while the canvas-area
// scrolls (the overlay is anchored to the canvas's live rect, so a
// re-draw on scroll re-reads its position).
@@ -2879,6 +3008,7 @@ function _buildEditor(container) {
applyImageTool: _applyImageTool,
uiModule,
});
_wireAiCommandBox();
// Merge / Flatten buttons (layer-panel footer) — full
// implementation in editor/wire-merge-buttons.js.
+4 -1
View File
@@ -6,7 +6,10 @@ import Storage from './storage.js';
function clearFreshComposerRestore() {
const msgInput = document.getElementById('message');
if (!msgInput) return;
const hasSessionTarget = !!(window.location.hash || Storage.get('lastSessionId'));
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'));
if (hasSessionTarget) return;
if (msgInput.value) {
msgInput.value = '';
+12 -1
View File
@@ -165,7 +165,7 @@ export function hasUnclosedThinkTag(text) {
}
export function startsWithReasoningPrefix(text) {
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |user wants|we need |i need |i should |i will |i'll |i am going |let me (?:think|look|see|check|read|review|analyze|parse|figure|draft|write)|they are |the question |i can )/i.test(text || '');
}
export function normalizeThinkingMarkup(text) {
@@ -236,6 +236,11 @@ function normalizePlainThinking(text) {
}
}
if (/^\s*(?:thinking(?:\s+process)?\s*:|the user |user wants|we need |let me (?:think|look|see|check|read|review|analyze|parse|figure|draft|write)|i need to |i should |i will |i'll |i am going )/i.test(trimmed)) {
const thinkBlock = withoutPrefix.trim();
if (thinkBlock) return `<think>${thinkBlock}</think>`;
}
return text;
}
@@ -560,6 +565,12 @@ export function mdToHtml(src, opts) {
new RegExp(`(^|[^\\[(])#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\b`, 'g'),
'$1[#$2](#$2)',
);
// Legacy search_chats output used bare session hashes (`#<uuid>`). Upgrade
// those too so old answers and model summaries remain clickable.
s = s.replace(
/(^|[^\[(])#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi,
'$1[#session-$2](#session-$2)',
);
// Convert markdown images before links so ![alt](url) does not become
// literal "!" plus a normal link.
+20 -25
View File
@@ -121,7 +121,7 @@ async function _ensureDefaultPendingChat() {
if (!_deps || _defaultChatPickInFlight) return;
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
const pending = _deps.getPendingChat && _deps.getPendingChat();
if (pending && pending.modelId) return;
if (pending && pending.modelId && pending.source === 'manual') return;
_defaultChatPickInFlight = true;
try {
await _ensureModelCacheForFallback();
@@ -131,20 +131,26 @@ async function _ensureDefaultPendingChat() {
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(/\/+$/, '');
const defaultUrl = String(dc.endpoint_url || '').replace(/\/+$/, '');
_deps.setPendingChat({
url: dc.endpoint_url,
modelId: dc.model,
endpointId: dc.endpoint_id || '',
source: 'default',
});
try { window.__odysseusDefaultChat = dc; } catch (_) {}
updateModelPicker();
if (!pending || pending.modelId !== dc.model || pendingUrl !== defaultUrl || pending.source !== 'default') {
updateModelPicker();
}
return;
}
if (pending && pending.modelId) return;
// No configured default, or the configured default is gone/offline:
// preserve the convenience fallback and keep the picker usable.
const fallback = _firstAvailableModel();
if (fallback) {
_deps.setPendingChat(fallback);
_deps.setPendingChat({ ...fallback, source: 'fallback' });
updateModelPicker();
}
} finally {
@@ -564,7 +570,7 @@ function _initModelPickerDropdown() {
}
if (!currentSessionId && _pendingChat) {
// Already have a deferred session — just update the model
_deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId });
_deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId, source: 'manual' });
// Header stays as session name — model switch only updates picker
updateModelPicker();
uiModule.showToast(`Using ${m.display}`);
@@ -607,7 +613,7 @@ function _initModelPickerDropdown() {
if ((current && current.model) || (pending && pending.modelId)) return;
if (window.modelsModule && window.modelsModule.refreshModels) {
try { await window.modelsModule.refreshModels(true); } catch (_) {}
try { await window.modelsModule.refreshModels(false); } catch (_) {}
}
const items = window.modelsModule && window.modelsModule.getCachedItems ? window.modelsModule.getCachedItems() : [];
const targetEndpointId = detail.endpointId ? String(detail.endpointId) : '';
@@ -656,12 +662,6 @@ function _initModelPickerDropdown() {
updateModelPicker();
}).catch(() => {});
}
// Kick off a local-endpoint probe — when it returns, re-render
// the list so stale local servers get dimmed. Cloud entries
// aren't probed; they stay visible.
_refreshLocalProbe().then(() => {
if (!menu.classList.contains('hidden')) _populate(search.value || '');
});
if (window.innerWidth >= 768) search.focus();
// Hide scroll button so it doesn't overlap
const _scrollBtn = document.getElementById('scroll-bottom-btn');
@@ -755,18 +755,6 @@ export function updateModelPicker() {
// we have no session model and no pending-chat pick, fall through to
// the "Select model" placeholder below.
//
// But if the server model cache already has an online endpoint, make the
// same safe fallback visible in the picker immediately. The send path can
// already resolve a usable model; the UI should not sit on "Select model"
// and make it look broken.
if (!modelId && !currentSessionId && window.modelsModule && window.modelsModule.getCachedItems) {
const fallback = _firstAvailableModel();
if (fallback) {
_deps.setPendingChat(fallback);
modelId = fallback.modelId;
}
}
// 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) {
@@ -781,11 +769,18 @@ export function updateModelPicker() {
const fallback = items.find(item => !item.offline && (item.models || []).length > 0);
if (fallback) {
modelId = fallback.models[0];
_deps.setPendingChat({ url: fallback.url, modelId, endpointId: fallback.endpoint_id });
_deps.setPendingChat({ url: fallback.url, modelId, endpointId: fallback.endpoint_id, source: 'fallback' });
}
}
}
if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) {
const latestPending = _deps.getPendingChat && _deps.getPendingChat();
if (
!currentSessionId &&
!_autoSelectingDefault &&
window.modelsModule &&
window.modelsModule.getCachedItems &&
(!modelId || (latestPending && latestPending.source === 'fallback'))
) {
_ensureDefaultPendingChat();
}
+18 -9
View File
@@ -17,6 +17,7 @@ let API_BASE = '';
let _cachedItems = []; // cached /api/models items for model-switch dropdown
let _lastFetchTime = 0;
let _fetchInflight = null;
let _fetchSeq = 0;
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
const COLLAPSE_KEY = 'odysseus-models-collapsed';
const FAVORITES_KEY = 'odysseus-model-favorites';
@@ -165,18 +166,21 @@ function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
export async function refreshModels(force = false) {
const box = document.getElementById('models');
if (!box) return;
// Skip network fetch if cache is fresh and not forced — still re-render UI
const now = Date.now();
const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
box.innerHTML = '';
if (box) box.innerHTML = '';
if (needsFetch) {
const _loadingSpinner = spinnerModule.create('', 'right', 'wave');
box.appendChild(_loadingSpinner.createElement());
_loadingSpinner.start();
let _loadingSpinner = null;
if (box) {
_loadingSpinner = spinnerModule.create('', 'right', 'wave');
box.appendChild(_loadingSpinner.createElement());
_loadingSpinner.start();
}
try {
if (force) _fetchInflight = null;
if (!_fetchInflight) {
// Pass ?refresh=true on forced refreshes so the BACKEND's 30s
// per-user cache also gets bypassed. Without this, `force=true`
@@ -184,25 +188,30 @@ export async function refreshModels(force = false) {
// back — newly-served endpoints don't appear until the cache
// ages out. (Bug repro: serve a model, picker is empty for ~30s
// even though the endpoint is in the DB and online.)
const _seq = ++_fetchSeq;
const _url = `${API_BASE}/api/models` + (force ? '?refresh=true' : '?background=false');
_fetchInflight = fetch(_url, { credentials: 'same-origin' })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
const data = await res.json();
return { data, seq: _seq };
})
.finally(() => { _fetchInflight = null; });
}
const data = await _fetchInflight;
const { data, seq } = await _fetchInflight;
if (seq < _fetchSeq) return;
_lastFetchTime = Date.now();
_cachedItems = data.items || [];
} catch (e) {
console.error(e);
box.textContent = '(scan failed)';
if (box) box.textContent = '(scan failed)';
return;
} finally {
box.innerHTML = '';
try { _loadingSpinner && _loadingSpinner.stop && _loadingSpinner.stop(); } catch (_) {}
if (box) box.innerHTML = '';
}
}
if (!box) return;
try {
const collapseState = _loadCollapsed();
+20 -19
View File
@@ -5322,25 +5322,26 @@ async function _initReminders() {
// just opening the panel when the card isn't found (panel still
// loading, note in a different filter, etc.).
async function openNote(noteId) {
// If the panel is already open, openPanel() short-circuits and does
// nothing — including no re-fetch — so a freshly-created note added
// server-side never shows up. Force a refresh by closing first when
// open, then re-opening. Clicking the sidebar Notes button as a
// last resort keeps this working even if the module state got out
// of sync (rare but seen during HMR or after a stuck modal).
try {
if (isPanelOpen && isPanelOpen()) {
closePanel();
// give the close animation a frame to settle
await new Promise(r => setTimeout(r, 30));
}
} catch (_) {}
openPanel();
// openPanel() kicks off _fetchNotes() asynchronously, so the cards
// for newly-created notes may not be in the DOM yet. Also poll the
// _notes module array directly — if the note IS loaded but the
// active filter (e.g. archive view) is hiding it, we can still
// surface a confirmation toast.
const wasOpen = !!(isPanelOpen && isPanelOpen());
_showingArchived = false;
_activeLabel = null;
_activeFilter = null;
_searchQuery = '';
if (!wasOpen) {
openPanel();
} else {
_bringNotesToFront();
const searchEl = document.getElementById('notes-search');
if (searchEl) searchEl.value = '';
const pane = document.getElementById('notes-pane');
if (pane) pane.classList.remove('notes-pane-archive');
const archiveBtn = document.getElementById('notes-archive-toggle');
if (archiveBtn) archiveBtn.classList.remove('active');
await _fetchNotes();
_renderNotes();
}
// openPanel() kicks off _fetchNotes() asynchronously, so the cards for
// newly-created notes may not be in the DOM yet. Poll until the card exists.
if (!noteId) return;
let tries = 0;
const findAndFlash = () => {
+53 -62
View File
@@ -3,7 +3,6 @@
import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js';
import markdownModule from './markdown.js';
import chatRenderer from './chatRenderer.js';
import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js';
@@ -110,6 +109,29 @@ function _historyUrl(id, { limit = null, offset = null } = {}) {
return url.toString();
}
function _addHistoryMessageWithFullRenderer(role, content, modelName, meta) {
const box = document.getElementById('chat-history');
if (!box) return [];
const marker = document.createComment('history-message');
box.appendChild(marker);
let rendered = null;
try {
rendered = chatRenderer.addMessage(role, content, modelName, meta);
} catch (e) {
marker.remove();
throw e;
}
const nodes = [];
let node = marker.nextSibling;
while (node) {
const next = node.nextSibling;
nodes.push(node);
node = next;
}
marker.remove();
return nodes.length ? nodes : (rendered ? [rendered] : []);
}
function _renderHistoryMessage(msg, modelName) {
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
let displayContent;
@@ -137,54 +159,7 @@ function _renderHistoryMessage(msg, modelName) {
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
}
}
const box = document.getElementById('chat-history');
if (!box) return null;
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
const wrap = document.createElement('div');
wrap.className = 'msg ' + (msg.role === 'user' ? 'msg-user' : 'msg-ai');
wrap.dataset.raw = displayContent;
if (meta?._db_id) wrap.dataset.dbId = meta._db_id;
const roleEl = document.createElement('div');
roleEl.className = 'role';
if (msg.role === 'user') {
roleEl.textContent = 'You';
} else {
const pair = chatRenderer.replyModelPair ? chatRenderer.replyModelPair(modelName, meta) : {};
const resolved = pair.actualModel || pair.requestedModel || modelName;
roleEl.textContent = chatRenderer.modelRouteLabel
? chatRenderer.modelRouteLabel(pair.requestedModel, resolved)
: (resolved || 'Odysseus');
if (chatRenderer.applyModelColor) chatRenderer.applyModelColor(roleEl, resolved);
}
const timestamp = meta?.timestamp;
if (timestamp) {
const ts = document.createElement('span');
ts.className = 'msg-time';
try {
ts.textContent = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
ts.textContent = '';
}
roleEl.appendChild(ts);
}
const body = document.createElement('div');
body.className = 'body';
body.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(markdownModule.renderContent(displayContent || ''))
);
if (msg.role === 'user' && Array.isArray(meta?.attachments) && meta.attachments.length) {
if (chatRenderer.buildAttachCards) {
body.appendChild(chatRenderer.buildAttachCards(meta.attachments));
}
}
wrap.appendChild(roleEl);
wrap.appendChild(body);
box.appendChild(wrap);
return wrap;
return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta);
}
function _clearHistoryPager() {
@@ -232,8 +207,8 @@ function _installHistoryPager(id, pageInfo, modelName) {
const newEls = [];
for (const msg of data.history || []) {
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const el = _renderHistoryMessage(msg, _historyPager.modelName);
if (el) newEls.push(el);
const els = _renderHistoryMessage(msg, _historyPager.modelName);
if (Array.isArray(els)) newEls.push(...els);
}
for (const el of newEls) {
box.insertBefore(el, anchor || box.firstChild);
@@ -1661,7 +1636,10 @@ export async function loadSessions() {
// most recently appended a message.
const _isTransient = (s) => !!s && (s.folder === 'Assistant' || s.folder === 'Tasks');
const _realSessions = activeSessions.filter(s => !_isTransient(s));
const hashId = window.location.hash.replace('#', '');
let hashId = window.location.hash.replace('#', '');
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes&note=/.test(hashId)) {
hashId = '';
}
let savedId = Storage.get('lastSessionId');
// If the persisted lastSessionId points to a transient session (legacy
// state from before the persistence-guard was added), drop it.
@@ -1780,7 +1758,7 @@ export async function loadSessions() {
}
}
export async function selectSession(id, { keepSidebar = false, showLoading = true } = {}) {
export async function selectSession(id, { keepSidebar = false, showLoading = true, immediateLoading = false } = {}) {
// Exit compare mode cleanly if active
if (window.compareModule && window.compareModule.isActive()) {
window.compareModule.deactivate(true);
@@ -1898,7 +1876,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
let loadingPaintReady = Promise.resolve();
if (!isOC) {
if (showLoading && chatHistory && prevSessionId !== id) {
const loadingDelayMs = window.innerWidth <= 768 ? 900 : 500;
const loadingDelayMs = immediateLoading ? 0 : (window.innerWidth <= 768 ? 900 : 500);
loadingTimer = setTimeout(() => {
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
_paintSessionLoading(chatHistory, 'Loading chat');
@@ -1990,8 +1968,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
} else {
if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen();
// Don't highlight empty sessions — feels like nothing is selected
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
// Don't highlight ordinary empty sessions — feels like nothing is
// selected. Keep document/email-scoped sessions highlighted though: a
// new email/reply chat starts empty but immediately owns an email doc.
const isDocScopedEmptySession = !!(meta && (meta.has_documents || /^Email:|^New Email$/i.test(meta.name || '')));
if (!isDocScopedEmptySession) {
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
}
}
uiModule.scrollHistoryInstant();
if (!isOC && msgHistory.length) {
@@ -2078,9 +2061,16 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
uiModule.showError('Failed to load session: ' + error.message);
} finally {
// Ensure memories are loaded after session selection
// Memory warmup must not block chat switching. The memories panel can load
// on demand; this is only a delayed cache refresh when the foreground chat
// is idle.
if (window.memoryModule && window.memoryModule.loadMemories) {
await window.memoryModule.loadMemories();
setTimeout(() => {
const busy = !!window.__odysseusChatBusy
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending');
if (!busy) window.memoryModule.loadMemories().catch(() => {});
}, 2500);
}
// Auto-focus message input (unless session list has keyboard focus).
// Skip on mobile — focusing the textarea pops up the on-screen keyboard,
@@ -2208,10 +2198,11 @@ export async function materializePendingSession() {
Storage.set('lastSessionId', payload.id);
history.replaceState(null, '', '#' + payload.id);
// Reload sidebar to show the new session — await it so the session
// is fully registered before the caller proceeds (prevents race conditions)
// 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;
await loadSessions().catch(() => {});
loadSessions().catch(() => {});
return true;
}
@@ -2345,7 +2336,7 @@ export function initDragSort() {
// session navigation (which would reset the active chat).
window.addEventListener('hashchange', () => {
const hashId = window.location.hash.replace('#', '');
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId)) return;
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes&note=/.test(hashId)) return;
if (hashId && hashId !== currentSessionId) {
const target = sessions.find(s => s.id === hashId && !s.archived);
if (target) selectSession(hashId);
+29 -16
View File
@@ -3106,6 +3106,28 @@ async function initEmailSettings() {
const root = el('settings-modal');
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
const styleKey = 'odysseus-email-writing-style';
const styleEl = el('set-email-style');
// 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);
if (styleEl && cachedStyle !== null && !styleEl.value) styleEl.value = cachedStyle;
} catch (_) {}
const loadWritingStyle = async () => {
try {
const res = await fetch('/api/email/style');
const data = await res.json();
const style = data.style || '';
if (styleEl) styleEl.value = style;
try { localStorage.setItem(styleKey, style); } catch (_) {}
} catch (_) {}
};
loadWritingStyle();
// Load current email config
try {
const res = await fetch('/api/email/config');
@@ -3119,8 +3141,6 @@ async function initEmailSettings() {
if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || '';
if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = '';
if (el('set-email-from')) el('set-email-from').value = cfg.from_address || '';
if (el('set-email-auto-translate')) el('set-email-auto-translate').checked = !!cfg.email_auto_translate;
if (el('set-email-translate-language')) el('set-email-translate-language').value = cfg.email_translate_language || 'English';
} catch (_) {}
// Load contacts config
@@ -3132,13 +3152,6 @@ async function initEmailSettings() {
if (el('set-carddav-pass')) el('set-carddav-pass').value = '';
} catch (_) {}
// Load writing style
try {
const res = await fetch('/api/email/style');
const data = await res.json();
if (el('set-email-style')) el('set-email-style').value = data.style || '';
} catch (_) {}
// Save email config
el('set-email-save')?.addEventListener('click', async () => {
const msg = el('set-email-msg');
@@ -3151,8 +3164,6 @@ async function initEmailSettings() {
smtp_port: parseInt(el('set-email-smtp-port').value) || 0,
smtp_user: el('set-email-smtp-user').value,
email_from: el('set-email-from').value,
email_auto_translate: !!el('set-email-auto-translate')?.checked,
email_translate_language: (el('set-email-translate-language')?.value || 'English').trim() || 'English',
};
const imapPass = el('set-email-imap-pass').value;
const smtpPass = el('set-email-smtp-pass').value;
@@ -3166,10 +3177,7 @@ async function initEmailSettings() {
});
const result = await res.json();
if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
const translateMsg = el('set-email-translate-msg');
if (translateMsg) translateMsg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
setTimeout(() => { const translateMsg = el('set-email-translate-msg'); if (translateMsg) translateMsg.textContent = ''; }, 3000);
} catch (e) {
if (msg) msg.textContent = 'Failed';
}
@@ -3234,7 +3242,8 @@ async function initEmailSettings() {
});
const data = await res.json();
if (data.success && data.style) {
if (el('set-email-style')) el('set-email-style').value = data.style;
if (styleEl) styleEl.value = data.style;
try { localStorage.setItem(styleKey, data.style); } catch (_) {}
if (msg) msg.textContent = '✓ Style extracted';
} else {
if (msg) msg.textContent = data.error || 'Failed';
@@ -3253,12 +3262,16 @@ async function initEmailSettings() {
const msg = el('set-email-style-msg');
if (msg) msg.textContent = 'Saving...';
try {
const style = styleEl ? styleEl.value : '';
const res = await fetch('/api/email/style', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ style: el('set-email-style').value }),
body: JSON.stringify({ style }),
});
const result = await res.json();
if (result.success) {
try { localStorage.setItem(styleKey, style); } catch (_) {}
}
if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
} catch (e) {
+4 -3
View File
@@ -92,10 +92,11 @@ export function initSidebarLayout(Storage, opts) {
});
}
// New chat buttons — same as clicking brand
// Header-only new-chat aliases. #sidebar-new-chat-btn is wired in app.js
// because it needs the full default-model/pending-chat flow; wiring it here
// as well caused duplicate click handling and occasional no-op/race behavior.
const chatNewBtn = document.getElementById('chat-new-btn');
const sidebarNewChat = document.getElementById('sidebar-new-chat-btn');
[chatNewBtn, sidebarNewChat].forEach(btn => {
[chatNewBtn].forEach(btn => {
if (btn) btn.addEventListener('click', () => {
const brandBtn = document.getElementById('sidebar-brand-btn');
if (brandBtn) brandBtn.click();
+754 -11
View File
@@ -3579,6 +3579,10 @@ body.bg-pattern-sparkles {
display: inline-flex; align-items: center; justify-content: center;
}
.footer-copy-btn:hover { color:var(--accent); }
.footer-open-gallery-btn {
gap: 4px;
font-size: 11px;
}
/* Delete action same chrome as copy/download/edit but hover reveals
the destructive red tint so the user can tell it's not a benign op. */
.footer-delete-btn:hover { color: var(--red); }
@@ -4135,7 +4139,7 @@ body.bg-pattern-sparkles {
to { stroke-dashoffset: 0; }
}
.toast-close-btn {
margin-left: 8px;
margin-left: auto;
padding: 0;
width: 22px;
height: 22px;
@@ -8168,6 +8172,11 @@ button.hamburger {
.msg a:hover {
border-bottom-color: var(--hl-function, #5b8def);
}
.msg a.is-loading {
opacity: 0.65;
border-bottom-color: var(--accent, #bd93f9);
cursor: progress;
}
/* Tables */
.msg table {
@@ -8675,6 +8684,98 @@ button.hamburger {
.thumb.thumb-image button:active {
transform: scale(0.96);
}
.attach-crop-overlay {
position: fixed;
inset: 0;
z-index: var(--attach-crop-z, 100001);
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
box-sizing: border-box;
}
.attach-crop-panel {
width: min(94vw, 520px);
max-height: min(86vh, 720px);
background: var(--panel, #111);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
overflow: hidden;
display: flex;
flex-direction: column;
}
.attach-crop-stage {
position: relative;
min-height: 280px;
height: min(66vh, 560px);
background: #050505;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
}
.attach-crop-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
user-select: none;
pointer-events: none;
}
.attach-crop-box {
position: absolute;
border: 2px solid var(--accent, var(--red));
box-shadow: 0 0 0 9999px rgba(0,0,0,0.42);
cursor: move;
touch-action: none;
box-sizing: border-box;
}
.attach-crop-box::before,
.attach-crop-box::after {
content: '';
position: absolute;
inset: 33.333% 0 auto;
border-top: 1px solid rgba(255,255,255,0.38);
}
.attach-crop-box::after {
inset: 66.666% 0 auto;
}
.attach-crop-handle {
position: absolute;
right: -9px;
bottom: -9px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--accent, var(--red));
border: 2px solid var(--bg);
box-sizing: border-box;
cursor: nwse-resize;
}
.attach-crop-actions {
display: flex;
gap: 8px;
padding: 10px;
background: color-mix(in srgb, var(--bg) 78%, transparent);
}
.attach-crop-btn {
flex: 1;
height: 34px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--fg);
font: inherit;
font-size: 12px;
}
.attach-crop-primary {
background: var(--accent, var(--red));
color: #fff;
border-color: var(--accent, var(--red));
}
@media (max-width: 768px) {
/* Collapsed "N files" badge: use the same corner-X accent badge as image thumbs. */
.thumb-collapsed { position: relative; }
@@ -18075,6 +18176,10 @@ body.gallery-selecting .gallery-dl-btn,
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.gallery-card.gallery-card-focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 36%, transparent);
}
/* Upload-affordance tile pinned to the top-left of the Photos grid. Matches
the Upload album tile in the Albums tab dashed border, centered icon. */
.gallery-card-upload {
@@ -19067,6 +19172,14 @@ body.gallery-selecting .gallery-dl-btn,
}
.cookbook-field-input:focus { border-color: var(--red); }
#hwfit-dl-server, #hwfit-usecase, #hwfit-server-select, #hwfit-cache-server, #serve-sort { height: 28px; width: 88px; }
#hwfit-dl-server.cookbook-server-select-colored,
#hwfit-server-select.cookbook-server-select-colored,
#hwfit-cache-server.cookbook-server-select-colored,
#hwfit-deps-server.cookbook-server-select-colored {
color: var(--cookbook-server-color, var(--fg));
border-color: color-mix(in srgb, var(--cookbook-server-color) 55%, var(--border));
background-color: color-mix(in srgb, var(--cookbook-server-color) 12%, var(--bg));
}
/* Serve tab match Documents sizing, but leave room for the active "Cancel"
label and center it vertically so the descenders don't clip. */
#hwfit-cache-select {
@@ -19723,6 +19836,25 @@ body.gallery-selecting .gallery-dl-btn,
background: color-mix(in srgb, var(--fg) 9%, transparent);
border-color: color-mix(in srgb, var(--fg) 22%, transparent);
}
.cookbook-section-header.has-server-color {
color: var(--fg);
border-color: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 45%, var(--border));
background: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 4%, var(--bg));
box-shadow: inset 6px 0 0 var(--cookbook-server-accent, var(--cookbook-server-color));
}
.cookbook-section-header.has-server-color:hover {
background: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 7%, var(--bg));
border-color: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 60%, var(--border));
}
.cookbook-section-header.has-server-color .cookbook-section-title,
.cookbook-section-header.has-server-color .cookbook-section-chevron {
color: var(--cookbook-server-accent, var(--cookbook-server-color));
}
.cookbook-section-header.has-server-color .cookbook-srv-status.ok {
background: var(--cookbook-server-accent, var(--cookbook-server-color));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 18%, transparent),
0 0 8px color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 55%, transparent);
}
.cookbook-section-header .cookbook-section-title {
flex: 1;
}
@@ -20795,6 +20927,12 @@ body.gallery-selecting .gallery-dl-btn,
border-color: var(--border);
color: var(--fg);
}
.cookbook-task-menu-btn.cookbook-menu-active {
opacity: 1 !important;
background: color-mix(in srgb, var(--fg) 9%, transparent);
border-color: var(--border);
color: var(--fg);
}
@media (max-width: 768px) {
.cookbook-task .cookbook-task-menu-btn {
opacity: 0.72;
@@ -22146,7 +22284,7 @@ body.gallery-selecting .gallery-dl-btn,
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-left: 3px solid color-mix(in srgb, var(--fg) 30%, transparent);
border-left: 3px solid var(--cookbook-server-color, color-mix(in srgb, var(--fg) 30%, transparent));
border-radius: 8px;
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
box-sizing: border-box;
@@ -22159,6 +22297,113 @@ body.gallery-selecting .gallery-dl-btn,
.cookbook-server-row .cookbook-srv-path { flex: 1 1 100% !important; width: auto !important; min-width: 0 !important; }
}
.cookbook-server-row .cookbook-srv-name { width: 60px; flex-shrink: 0; flex-grow: 0; }
.cookbook-server-row .cookbook-srv-color-wrap {
width: 86px;
flex-shrink: 0;
flex-grow: 0;
display: inline-flex;
align-items: center;
position: relative;
color: var(--cookbook-server-color, var(--fg-muted));
}
.cookbook-server-row select.cookbook-srv-color {
display: none !important;
}
.cookbook-server-row .cookbook-srv-color-dot {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
z-index: 1;
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot {
opacity: 0.8;
}
.cookbook-server-row .cookbook-srv-color-btn {
width: 100%;
padding-left: 22px !important;
padding-right: 18px !important;
position: relative;
text-align: left;
cursor: pointer;
overflow: hidden;
}
.cookbook-server-row .cookbook-srv-color-label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cookbook-server-row .cookbook-srv-color-caret {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
opacity: 0.65;
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-btn {
color: var(--fg);
border-color: color-mix(in srgb, var(--cookbook-server-color, var(--border)) 55%, var(--border));
background-color: color-mix(in srgb, var(--cookbook-server-color, var(--fg)) 14%, var(--bg));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--cookbook-server-color, var(--border)) 18%, transparent);
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot {
background: var(--cookbook-server-color, currentColor);
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-caret {
color: var(--fg-muted);
}
.cookbook-srv-color-menu {
position: absolute;
z-index: 5000;
top: calc(100% + 4px);
left: 0;
width: 138px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
box-shadow: 0 8px 24px color-mix(in srgb, #000 35%, transparent);
}
.cookbook-srv-color-menu.hidden {
display: none;
}
.cookbook-srv-color-item {
width: 100%;
height: 24px;
border: 1px solid transparent;
border-radius: 4px;
background: color-mix(in srgb, var(--swatch-color, var(--fg)) 10%, transparent);
color: var(--fg);
display: flex;
align-items: center;
gap: 7px;
padding: 0 7px;
font: inherit;
font-size: 11px;
cursor: pointer;
text-align: left;
}
.cookbook-srv-color-item:hover,
.cookbook-srv-color-item.active {
border-color: color-mix(in srgb, var(--swatch-color, var(--accent, var(--red))) 55%, var(--border));
background: color-mix(in srgb, var(--swatch-color, var(--accent, var(--red))) 18%, var(--bg));
}
.cookbook-srv-color-item-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--swatch-color, var(--fg-muted));
border: 1px solid color-mix(in srgb, var(--fg) 18%, transparent);
flex-shrink: 0;
}
.cookbook-server-row .cookbook-srv-host { flex: 1; min-width: 100px; }
.cookbook-server-row .cookbook-srv-host[readonly] { opacity: 0.4; cursor: default; }
.cookbook-server-row .cookbook-srv-port { width: 40px; flex-shrink: 0; flex-grow: 0; }
@@ -24197,6 +24442,36 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
border-color: var(--red);
}
@media (max-width: 768px) {
/* Deep Research settings contain desktop-sized inline widths. On mobile,
let each control claim the card width instead of escaping the admin block. */
.settings-row:has(#set-researchSearch),
.settings-row:has(#set-researchMaxTokens),
.settings-row:has(#set-researchExtractTimeout),
.settings-row:has(#set-researchExtractConcurrency),
.settings-row:has(#set-researchRunTimeout) {
flex-wrap: wrap;
align-items: stretch;
}
.settings-row:has(#set-researchSearch) .settings-label,
.settings-row:has(#set-researchMaxTokens) .settings-label,
.settings-row:has(#set-researchExtractTimeout) .settings-label,
.settings-row:has(#set-researchExtractConcurrency) .settings-label,
.settings-row:has(#set-researchRunTimeout) .settings-label {
flex: 1 1 auto;
}
#set-researchSearch,
#set-researchMaxTokens,
#set-researchRunTimeout,
.settings-row div:has(> #set-researchExtractTimeout),
.settings-row div:has(> #set-researchExtractConcurrency) {
width: 100% !important;
max-width: 100% !important;
flex: 1 1 100% !important;
margin-left: 0 !important;
}
}
/* Default-chat fallback chain editor. Each row mirrors the primary
endpoint/model selectors, indented under them to read as a chain. */
.settings-fallbacks {
@@ -24735,8 +25010,17 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
}
#hwfit-cache-scan {
position: relative;
top: 1px;
width: 51px;
top: -1px;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
#hwfit-cache-scan.spinning svg {
animation: modelPickerRefreshSpin 0.75s linear infinite;
}
#serve-search {
height: 28px;
@@ -25996,6 +26280,126 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
background: color-mix(in srgb, var(--bg) 30%, transparent);
pointer-events: none;
}
.ge-ai-command {
position: absolute;
left: 50%;
bottom: 12px;
z-index: 18;
width: min(430px, calc(100% - 28px));
transform: translateX(-50%);
padding: 7px;
box-sizing: border-box;
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 42%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, var(--panel, var(--bg)) 94%, transparent);
box-shadow: 0 8px 22px color-mix(in srgb, #000 26%, transparent);
backdrop-filter: blur(8px);
}
.ge-ai-command-toggle {
width: 100%;
height: 28px;
display: none;
align-items: center;
justify-content: center;
gap: 5px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--accent, var(--red));
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.ge-ai-command-collapsed {
width: auto;
min-width: 112px;
padding: 2px 8px;
}
.ge-ai-command-collapsed .ge-ai-command-toggle {
display: inline-flex;
}
.ge-ai-command-collapsed .ge-ai-command-form,
.ge-ai-command-collapsed .ge-ai-command-status {
display: none;
}
.ge-ai-command-form {
display: flex;
align-items: center;
gap: 6px;
}
.ge-ai-command-input {
flex: 1 1 auto;
min-width: 0;
height: 32px;
box-sizing: border-box;
padding: 0 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: color-mix(in srgb, var(--bg) 76%, #000 24%);
color: var(--fg);
font: inherit;
font-size: 12px;
}
.ge-ai-command-input:focus {
outline: none;
border-color: var(--accent, var(--red));
}
.ge-ai-command-run {
background: var(--send-btn-bg, var(--red));
color: #fff;
border: none;
border-radius: 8px;
min-width: 32px;
width: 32px;
height: 32px !important;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
transition: background 0.25s, opacity 0.12s;
overflow: hidden;
}
.ge-ai-command-run:hover {
background: var(--send-btn-hover, color-mix(in srgb, var(--red) 80%, white));
}
.ge-ai-command-close {
width: 28px;
height: 32px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--fg);
opacity: 0.52;
cursor: pointer;
padding: 0;
}
.ge-ai-command-close:hover {
opacity: 0.9;
background: color-mix(in srgb, var(--fg) 10%, transparent);
}
.ge-ai-command-status {
min-height: 13px;
margin-top: 4px;
font-size: 10px;
line-height: 1.3;
color: var(--fg-muted);
}
.ge-ai-command-status[data-kind="running"] {
color: var(--accent, var(--red));
}
.ge-ai-command-status[data-kind="done"] {
color: var(--green, #4caf50);
}
.ge-ai-command-status[data-kind="error"] {
color: var(--red, #e5484d);
}
/* Loading overlay shown while a large image is decoding centered
whirlpool + "Loading" caption over a translucent dim. */
.ge-loading-overlay {
@@ -30891,6 +31295,39 @@ button .spinner-whirlpool {
cursor: pointer; user-select: none;
letter-spacing: 0.4px;
}
.email-attachments-download-all {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 35%, transparent);
background: color-mix(in srgb, var(--accent-primary, var(--red)) 9%, transparent);
color: var(--accent-primary, var(--red));
font: inherit;
font-size: 10px;
font-weight: 700;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
flex: 0 0 auto;
}
.email-attachments-download-all:hover {
background: color-mix(in srgb, var(--accent-primary, var(--red)) 18%, transparent);
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 60%, transparent);
}
.email-attachments-download-all.is-loading {
opacity: 0.82;
pointer-events: none;
}
.email-attachments-download-all .spinner-whirlpool,
.email-attachments-download-all .ai-spinner-whirlpool {
width: 12px !important;
height: 12px !important;
margin: 0 !important;
}
.email-reader-atts-wrap > .email-reader-atts {
border-bottom: none !important;
}
@@ -31503,6 +31940,34 @@ body.doc-find-active mark.doc-find-mark.current {
.email-filter-chip-clear:hover { opacity: 1; }
.email-date { font-size: 10px; opacity: 0.5; white-space: nowrap; flex-shrink: 0; }
.email-subject { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.email-sent-chip,
.email-folder-chip {
display: inline-flex;
align-items: center;
height: 14px;
padding: 0 5px;
margin-right: 5px;
border-radius: 999px;
font-size: 8px;
line-height: 1;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 28%, transparent);
vertical-align: 1px;
}
.email-folder-chip {
color: color-mix(in srgb, var(--fg) 75%, transparent);
background: rgba(127, 127, 127, 0.13);
border-color: rgba(127, 127, 127, 0.24);
}
@media (max-width: 768px) {
.email-search-row {
flex-wrap: wrap;
}
}
.email-tags { display: inline-flex; gap: 3px; margin-left: 6px; vertical-align: middle; position: relative; top: -2px; }
.email-card-expanded .email-tags,
.email-card-reader .email-tags { top: -4px; }
@@ -32259,23 +32724,269 @@ body.doc-find-active mark.doc-find-mark.current {
/* Compose attachment chips (when sending new email) */
.email-compose-atts {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 6px 0 0 58px;
display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 6px;
padding: 6px 0 0 0;
}
.email-compose-chip {
display: inline-flex; align-items: center; gap: 4px;
flex: 0 1 auto;
min-width: 0;
max-width: min(240px, 100%);
padding: 4px 4px 4px 8px; font-size: 11px;
background: var(--hover-bg, rgba(255,255,255,0.05));
border: 1px solid var(--border); border-radius: 12px; color: var(--fg);
}
.email-compose-chip .compose-chip-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email-compose-chip .att-size { opacity: 0.5; font-size: 10px; }
.email-compose-chip .compose-chip-name { max-width: 180px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email-compose-chip .att-size { opacity: 0.5; font-size: 10px; flex-shrink: 0; }
.email-compose-chip .compose-chip-remove {
background: none; border: none; color: var(--fg); opacity: 0.5;
font-size: 16px; line-height: 1; padding: 0 4px; cursor: pointer;
flex-shrink: 0;
}
.email-compose-chip .compose-chip-remove:hover { opacity: 1; color: var(--red, #e55); }
.email-odysseus-attach-menu {
position: fixed;
z-index: 10050;
width: 300px;
max-width: calc(100vw - 16px);
max-height: min(420px, calc(100vh - 16px));
padding: 7px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
color: var(--fg);
box-shadow: 0 12px 30px rgba(0,0,0,0.28);
overflow: hidden;
}
.email-odysseus-attach-local {
width: 100%;
height: 30px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 35%, var(--border));
border-radius: 5px;
background: color-mix(in srgb, var(--accent-primary, var(--red)) 9%, transparent);
color: var(--fg);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.email-odysseus-attach-local:hover {
background: color-mix(in srgb, var(--accent-primary, var(--red)) 16%, transparent);
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 55%, var(--border));
}
.email-odysseus-attach-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
margin: 7px 0;
}
.email-odysseus-attach-tabs button {
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 5px;
background: transparent;
color: var(--fg);
opacity: 0.72;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.email-odysseus-attach-tabs button.active {
opacity: 1;
color: var(--accent-primary, var(--red));
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
background: color-mix(in srgb, var(--accent-primary, var(--red)) 8%, transparent);
}
.email-odysseus-attach-tabs button svg {
flex-shrink: 0;
}
.email-odysseus-attach-search-wrap {
height: 28px;
display: flex;
align-items: center;
gap: 6px;
margin: -1px 0 7px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: color-mix(in srgb, var(--fg) 3%, transparent);
color: color-mix(in srgb, var(--fg) 48%, transparent);
}
.email-odysseus-attach-search-wrap:focus-within {
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
color: var(--accent-primary, var(--red));
background: color-mix(in srgb, var(--accent-primary, var(--red)) 6%, transparent);
}
.email-odysseus-attach-search {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--fg);
font: inherit;
font-size: 11px;
}
.email-odysseus-attach-search::placeholder {
color: color-mix(in srgb, var(--fg) 38%, transparent);
}
.email-odysseus-attach-list {
max-height: 320px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.email-odysseus-attach-row {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 7px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--fg);
text-align: left;
font: inherit;
cursor: pointer;
}
.email-odysseus-attach-row:hover {
background: var(--hover-bg, rgba(255,255,255,0.06));
border-color: var(--border);
}
.email-odysseus-attach-row.is-selected {
background: color-mix(in srgb, var(--accent-primary, var(--red)) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
}
.email-odysseus-attach-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
display: inline-block;
border: 1px solid var(--border);
border-radius: 50%;
background: color-mix(in srgb, var(--fg) 4%, transparent);
}
.email-odysseus-attach-row.is-selected .email-odysseus-attach-dot {
border-color: var(--accent-primary, var(--red));
background: var(--accent-primary, var(--red));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-primary, var(--red)) 18%, transparent);
}
.email-odysseus-attach-icon,
.email-odysseus-attach-thumb {
width: 28px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: color-mix(in srgb, var(--fg) 7%, transparent);
overflow: hidden;
}
.email-odysseus-attach-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.email-odysseus-attach-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.email-odysseus-attach-title,
.email-odysseus-attach-meta {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-odysseus-attach-title {
font-size: 11px;
line-height: 1.2;
}
.email-odysseus-attach-meta {
font-size: 10px;
line-height: 1.15;
opacity: 0.52;
}
.email-odysseus-attach-empty {
padding: 14px 8px;
color: var(--muted, #888);
font-size: 11px;
text-align: center;
}
.email-odysseus-attach-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 7px;
padding-top: 7px;
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
background: var(--bg);
position: sticky;
bottom: 0;
}
.email-odysseus-attach-count {
flex: 1;
min-width: 0;
color: var(--muted, #888);
font-size: 11px;
}
.email-odysseus-attach-selected {
height: 28px;
min-width: 72px;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
background: var(--accent-primary, var(--red));
color: #fff;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.email-odysseus-attach-selected svg {
flex-shrink: 0;
}
.email-odysseus-attach-selected:disabled {
opacity: 0.45;
cursor: default;
}
.email-odysseus-attach-selected.is-loading {
opacity: 0.72;
pointer-events: none;
}
@media (max-width: 768px) {
.email-odysseus-attach-menu {
left: 8px !important;
right: 8px;
bottom: 8px;
top: auto !important;
width: auto;
max-height: 58vh;
}
.email-odysseus-attach-list {
max-height: calc(58vh - 88px);
}
}
.email-cc-toggle {
background: none; border: none; color: var(--fg);
opacity: 0.4; font-size: 11px; cursor: pointer;
@@ -32313,8 +33024,12 @@ body.doc-find-active mark.doc-find-mark.current {
border-bottom: 1px solid var(--border);
}
.contact-suggestion:last-child { border-bottom: none; }
.contact-suggestion:hover, .contact-suggestion.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
.contact-suggestion:hover,
.contact-suggestion.active,
.contact-suggestion[aria-selected="true"] {
background: color-mix(in srgb, var(--accent-primary, var(--accent, var(--red))) 15%, transparent);
outline: 1px solid color-mix(in srgb, var(--accent-primary, var(--accent, var(--red))) 40%, transparent);
outline-offset: -1px;
}
.contact-suggestion .contact-name { font-weight: 600; color: var(--fg); }
.contact-suggestion .contact-email { opacity: 0.6; font-size: 11px; }
@@ -36135,7 +36850,15 @@ button.cal-add-btn.cal-add-btn-text {
transition: background 0.15s, border-color 0.15s;
}
/* Settings "+ Add server" matches the model-dir "+ Add" path button (22px). */
#cookbook-server-add.cal-add-btn-text { height: 21px; border-radius: 11px; position: relative; top: 3px; }
#cookbook-server-add.cal-add-btn-text {
height: 28px;
min-width: 62px;
border-radius: 14px;
padding: 0 12px 0 8px;
position: relative;
top: 1px;
font-size: 12px;
}
button.cal-add-btn.cal-add-btn-text:hover {
background: color-mix(in srgb, var(--fg) 14%, transparent);
border-color: var(--accent);
@@ -39086,6 +39809,26 @@ body.theme-frosted .modal {
/* Nudge the Delete button 4px left. */
.research-job-action[data-action="delete"] { position: relative; right: 2px; }
@media (max-width: 768px) {
/* Mobile: the "Delete" label is too wide in Deep Research job rows; keep the
familiar trash action but make it icon-only. */
.research-job-action[data-action="delete"] {
width: 28px;
min-width: 28px;
padding-left: 0;
padding-right: 0;
gap: 0;
justify-content: center;
font-size: 0;
}
.research-job-action[data-action="delete"] svg {
width: 13px;
height: 13px;
margin: 0;
flex-shrink: 0;
}
}
/* "Standard" (uncategorized) research uses green everywhere set its --cat-color
to the success green so the Visual Report button matches the green badge. */
.research-job-card.done:not([data-category]) { --cat-color: var(--color-success); }
+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-v336';
const CACHE_NAME = 'odysseus-v344';
// 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
+1 -1
View File
@@ -254,6 +254,6 @@ def test_frontend_always_sends_explicit_allow_bash():
def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
"""chat.js must send allow_web_search=false when web toggle is off in agent mode."""
source = _CHAT_JS.read_text(encoding="utf-8")
assert "allow_web_search', 'false'" in source, (
assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, (
"Frontend must send explicit allow_web_search=false in agent mode when toggle is off"
)
+16 -4
View File
@@ -24,7 +24,11 @@ def _add_handler():
def _stub_store(monkeypatch):
created = []
monkeypatch.setattr(cr, "_fetch_contacts", lambda *a, **k: [])
monkeypatch.setattr(cr, "_create_contact", lambda name, email: created.append((name, email)) or True)
monkeypatch.setattr(
cr,
"_create_contact",
lambda name, email="", address="", phones=None: created.append((name, email, address, phones or [])) or True,
)
return created
@@ -33,10 +37,18 @@ def test_null_name_does_not_crash(_stub_store):
result = asyncio.run(handler({"name": None, "email": "x@y.com"}, _admin="admin"))
assert result["success"] is True
# name fell back to the email local-part instead of crashing.
assert _stub_store == [("x", "x@y.com")]
assert _stub_store == [("x", "x@y.com", "", [])]
def test_null_email_is_rejected_cleanly(_stub_store):
def test_null_email_does_not_crash(_stub_store):
handler = _add_handler()
result = asyncio.run(handler({"name": "Bob", "email": None}, _admin="admin"))
assert result == {"success": False, "error": "Email required"}
assert result["success"] is True
assert _stub_store == [("Bob", "", "", [])]
def test_phone_only_contact_is_allowed(_stub_store):
handler = _add_handler()
result = asyncio.run(handler({"name": "Bob", "email": None, "phone": "0805412 7841"}, _admin="admin"))
assert result["success"] is True
assert _stub_store == [("Bob", "", "", ["0805412 7841"])]
@@ -20,7 +20,9 @@ def test_background_status_poll_reconciles_into_local_tasks():
source = _read("static/js/cookbookRunning.js")
assert "const statusById = new Map(tasks.map(t => [t.session_id, t]));" in source
assert "const nextStatus = live.status === 'completed'" in source
assert "const completedByOutput = depDone || downloadDone;" in source
assert "const nextStatus = completedByOutput" in source
assert "live.status === 'completed'" in source
assert "? 'done'" in source
assert ": (live.status === 'error'" in source
assert "? 'error'" in source
@@ -75,7 +77,8 @@ def test_background_poll_recovers_done_for_stopped_dependency_install():
downgrading the card to crashed."""
source = _read("static/js/cookbookRunning.js")
assert "const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);" in source
assert "const combinedOutput = `${task.output || ''}\\n${live.output_tail || ''}`;" in source
assert "const depDone = !!task.payload?._dep && _depInstallSucceeded(combinedOutput);" in source
assert "(depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped')" in source
@@ -91,14 +94,14 @@ def test_background_poll_recovers_done_for_completed_download():
normalized = " ".join(source.split())
assert (
"const downloadDone = task.type === 'download' "
"&& String(task.output || '').includes('DOWNLOAD_OK');"
"&& String(combinedOutput || '').includes('DOWNLOAD_OK');"
) in normalized
def test_dependency_install_payload_keeps_env_path_for_refresh():
source = _read("static/js/cookbook.js")
assert "env_path: _envState.envPath || ''" in source
assert "env_path: targetEnvPath || ''" in source
def test_local_dependency_probe_refreshes_user_site_visibility():
+1 -1
View File
@@ -29,7 +29,7 @@ def test_diagnose_sglang_native_dependency_errors():
diagnosis = _diagnose_serve_output(output)
assert diagnosis is not None
assert "SGLang native dependencies" in diagnosis["message"]
assert "SGLang native kernel/runtime" in diagnosis["message"]
labels = [suggestion["label"] for suggestion in diagnosis["suggestions"]]
assert any("libnuma-dev" in label for label in labels)
assert any("python3.12-dev" in label for label in labels)
+1 -1
View File
@@ -17,6 +17,6 @@ def test_sglang_native_dependency_diagnosis_is_exposed_to_browser():
assert r"Python\.h" in source
assert r"libnuma\.so\.1" in source
assert "SGLang native dependencies" in source
assert "SGLang native kernel/runtime" in source
assert "libnuma-dev python3.12-dev build-essential" in source
assert "sglang-kernel" in source
@@ -31,8 +31,8 @@ def test_selected_server_helpers_prefer_profile_key_before_host_fallback():
def test_cookbook_submodules_resolve_visible_profile_selection():
assert "_serverByVal?.(_ssv)" in DOWNLOAD
assert "_serverByVal?.(_envState.remoteServerKey || host)" in DOWNLOAD
assert "_serverByVal?.(_envState.remoteServerKey || _zh)" in DOWNLOAD
assert "_serverByVal?.(_envState.remoteServerKey || _envState.remoteHost || '')" in DOWNLOAD
assert "_serverByVal?.(zombieCandidate.remoteServerKey || zombieCandidate.payload?.remote_server_key || _zh)" in DOWNLOAD
assert "_serverByVal(_envState.remoteServerKey || remoteHost)" in HWFIT
assert "hk: _currentServerValue()" in HWFIT
assert "sel.value = _currentServerValue();" in HWFIT
+29
View File
@@ -0,0 +1,29 @@
import src.agent_tools # noqa: F401 (break agent_tools<->tool_parsing import cycle)
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
def test_function_model_wrapper_runs_web_search_and_strips_markup():
raw = """Sure, let me check what's making headlines in Sweden today.
<function_model>
<function_call>web_search</function_call>
<parameters>{"query": "Sweden news today July 2026"}</parameters>
</function_model>"""
blocks = parse_tool_blocks(raw, skip_fenced=True)
assert len(blocks) == 1
assert blocks[0].tool_type == "web_search"
assert blocks[0].content == "Sweden news today July 2026"
assert strip_tool_blocks(raw, skip_fenced=True) == "Sure, let me check what's making headlines in Sweden today."
def test_function_model_wrapper_with_unknown_tool_is_stripped_but_not_executed():
raw = """Nope.
<function_model>
<function_call>launch_missiles</function_call>
<parameters>{"target": "moon"}</parameters>
</function_model>"""
assert parse_tool_blocks(raw, skip_fenced=True) == []
assert strip_tool_blocks(raw, skip_fenced=True) == "Nope."
+20
View File
@@ -37,3 +37,23 @@ def test_gemma_parser_does_not_strip_non_tool_fenced_metadata():
assert parse_tool_blocks(raw) == []
assert strip_tool_blocks(raw) == raw
def test_strip_raw_openai_function_json_leak():
raw = (
'{"function":{"arguments":"{\\"action\\":\\"search\\",\\"tex\\":\\"hi\\"}",'
'"name":"manage_memory"},"id":"call_memory_search1","type":"function"}]</|assistan|Done.'
)
assert strip_tool_blocks(raw) == "Done."
def test_strip_raw_openai_function_json_array_leak():
raw = (
'Before\n['
'{"function":{"arguments":"{\\"action\\":\\"add\\",\\"text\\":\\"x\\"}",'
'"name":"manage_memory"},"id":"call_memory_add1","type":"function"}'
']\nAfter'
)
assert strip_tool_blocks(raw) == "Before\nAfter"
+11 -11
View File
@@ -43,13 +43,12 @@ def _fake_sysctl(brand="Apple M2 Pro", memsize_gb=32, wired_mb=None, display_jso
return run
def test_mlx_models_hidden_on_metal():
"""MLX-quantized models can't be served by llama.cpp or Ollama (the only
Metal-capable engines Odysseus generates), so they must never be recommended
on Apple Silicon even though the catalog tags them as Apple-only."""
def test_mlx_models_visible_on_metal():
"""Apple Silicon can serve MLX models through the MLX engine, so Cookbook
should surface MLX rows on Metal while still hiding them on CUDA."""
results = rank_models(_metal_system(), limit=900)
mlx = [m for m in results if str(m.get("quant", "")).startswith("mlx-")]
assert mlx == [], f"MLX models surfaced but cannot be served: {[m['name'] for m in mlx]}"
assert mlx, "MLX models should be available on Apple Silicon / Metal hardware"
def _cuda_system():
@@ -65,17 +64,18 @@ def test_mlx_hidden_on_cuda_backend_unchanged():
assert mlx == []
def test_only_gguf_models_recommended_on_metal():
"""llama.cpp and Ollama (the only Metal engines) need GGUF. Safetensors-only
repos incl. vLLM-only AWQ/GPTQ/FP8 can't be served on Metal, so every
model recommended on Apple Silicon must ship a servable GGUF."""
def test_only_gguf_or_mlx_models_recommended_on_metal():
"""Metal recommendations must be servable locally: either GGUF for
llama.cpp/Ollama or MLX for the MLX engine."""
catalog = {m["name"]: m for m in get_models()}
unservable = [
r["name"] for r in rank_models(_metal_system(), limit=900)
if not (catalog.get(r["name"], {}).get("is_gguf")
if not (str(r.get("quant", "")).startswith("mlx-")
or str(r.get("name", "")).startswith(("mlx-community/", "lmstudio-community/"))
or catalog.get(r["name"], {}).get("is_gguf")
or catalog.get(r["name"], {}).get("gguf_sources"))
]
assert unservable == [], f"{len(unservable)} non-GGUF models on Metal, e.g. {unservable[:3]}"
assert unservable == [], f"{len(unservable)} non-servable models on Metal, e.g. {unservable[:3]}"
def test_qwen_catalog_entries_point_at_verified_gguf_repos():
+6 -6
View File
@@ -67,11 +67,9 @@ def test_manual_ram_mode_wipes_gpu_and_unified_flag():
assert "unified_memory" not in s
def test_simulated_metal_box_only_recommends_gguf():
def test_simulated_metal_box_only_recommends_gguf_or_mlx():
"""End-to-end: a simulated Metal box must rank exactly like a real Mac —
only models shipping a servable GGUF (llama.cpp/Ollama) survive. Before
'metal' was accepted, this box ranked as CUDA and surfaced safetensors-only
repos the Mac can't serve."""
only locally servable GGUF or MLX models survive."""
system = _apply_manual_hardware(
{"backend": "cuda", "available_ram_gb": 32.0, "total_ram_gb": 64.0},
manual_mode="gpu", manual_vram_gb="48", manual_backend="metal",
@@ -79,7 +77,9 @@ def test_simulated_metal_box_only_recommends_gguf():
catalog = {m["name"]: m for m in get_models()}
unservable = [
r["name"] for r in rank_models(system, limit=900)
if not (catalog.get(r["name"], {}).get("is_gguf")
if not (str(r.get("quant", "")).startswith("mlx-")
or str(r.get("name", "")).startswith(("mlx-community/", "lmstudio-community/"))
or catalog.get(r["name"], {}).get("is_gguf")
or catalog.get(r["name"], {}).get("gguf_sources"))
]
assert unservable == [], f"{len(unservable)} non-GGUF models on simulated Metal, e.g. {unservable[:3]}"
assert unservable == [], f"{len(unservable)} non-servable models on simulated Metal, e.g. {unservable[:3]}"
@@ -86,6 +86,28 @@ def test_normal_model_payload_keeps_temperature_above_one(monkeypatch):
assert payload["temperature"] == 1.2
def test_local_minimax_mlx_payload_gets_stability_defaults(monkeypatch):
import src.model_context as model_context
monkeypatch.setattr(model_context, "is_local_endpoint", lambda _url: True)
payload = {
"model": "cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
"temperature": 0.9,
}
llm_core._apply_local_generation_stability(
payload,
"http://192.168.1.22:8091/v1/chat/completions",
"cookietimeh/MiniMax-M2.7-BF16-ultra-uncensored-heretic-mlx-4Bit",
)
assert payload["temperature"] == 0.2
assert payload["top_p"] == 0.9
assert payload["top_k"] == 20
assert payload["max_tokens"] == 2048
assert payload["repetition_penalty"] == 1.12
def test_chatgpt_subscription_payload_omits_max_output_tokens():
# ChatGPT Subscription Codex API does not support max_output_tokens —
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
+8 -2
View File
@@ -27,6 +27,11 @@ def test_new_chat_prefers_pending_and_current_model_before_default():
def test_desktop_new_chat_actions_use_shared_preference_helper():
source = APP_JS.read_text(encoding="utf-8")
shared_handler = _slice(
source,
"async function _handleNewChatAction",
"// New session button on icon rail",
)
rail_handler = _slice(
source,
"// New session button on icon rail",
@@ -38,7 +43,8 @@ def test_desktop_new_chat_actions_use_shared_preference_helper():
"const sidebarNewChatBtn = el('sidebar-new-chat-btn');",
)
assert "if (await _createDirectChatFromPreferredModel()) return;" in rail_handler
assert "if (await _createDirectChatFromPreferredModel()) return;" in brand_handler
assert "if (preferModel && await _createDirectChatFromPreferredModel()) return;" in shared_handler
assert "await _handleNewChatAction();" in rail_handler
assert "await _handleNewChatAction();" in brand_handler
assert "const dc = await _refreshDefaultChat();" not in rail_handler
assert "const dc = await _refreshDefaultChat();" not in brand_handler
@@ -0,0 +1,38 @@
from src.agent_loop import _normalize_stream_document_fences
from src.tool_parsing import parse_tool_blocks
def test_truncated_update_document_fence_is_executable():
text = "```update_documen\n# Title\n\nSwedish body\n```"
normalized = _normalize_stream_document_fences(text, "update_document")
blocks = parse_tool_blocks(normalized)
assert len(blocks) == 1
assert blocks[0].tool_type == "update_document"
assert "Swedish body" in blocks[0].content
def test_truncated_edit_document_fence_is_executable():
text = (
"```edit_documen\n"
"<<<FIND>>>\nold\n<<<REPLACE>>>\nnew\n<<<END>>>\n"
"```"
)
normalized = _normalize_stream_document_fences(text, "update_document")
blocks = parse_tool_blocks(normalized)
assert len(blocks) == 1
assert blocks[0].tool_type == "edit_document"
def test_compact_truncated_edit_document_fence_is_executable():
text = "```edi_documen\n<<FIND>old\n<<REPLACE>new\n<<END>```\n|end|"
normalized = _normalize_stream_document_fences(text, "update_document")
blocks = parse_tool_blocks(normalized)
assert len(blocks) == 1
assert blocks[0].tool_type == "edit_document"
assert blocks[0].content == "<<<FIND>>>\nold\n<<<REPLACE>>>\nnew\n<<<END>>>"
+27
View File
@@ -516,6 +516,33 @@ async def test_app_api_blocks_cookbook_host_control_routes_before_loopback(monke
assert error_text in result["error"]
@pytest.mark.asyncio
async def test_app_api_blocks_search_route_before_loopback(monkeypatch):
import httpx
from src.tool_implementations import do_app_api
class UnexpectedAsyncClient:
def __init__(self, *args, **kwargs):
raise AssertionError("app_api should block search routes before loopback")
monkeypatch.setattr(httpx, "AsyncClient", UnexpectedAsyncClient)
result = await do_app_api(
json.dumps(
{
"action": "call",
"method": "GET",
"path": "/api/search",
"query": {"q": "crow box designs"},
}
),
owner="admin",
)
assert result["exit_code"] == 1
assert "use the `web_search` tool" in result["error"]
@pytest.mark.asyncio
async def test_app_api_endpoint_discovery_hides_shell_routes(monkeypatch):
_install_core_middleware_stub(monkeypatch)
@@ -55,3 +55,10 @@ def test_genuine_keywords_still_force_include():
assert "reply_to_email" in ti.get_tools_for_query("reply to this email")
assert "edit_document" in ti.get_tools_for_query("edit the document")
assert "serve_model" in ti.get_tools_for_query("serve the model")
def test_find_info_online_forces_web_search_tools():
ti = _index()
tools = ti.get_tools_for_query("find info online about crow box designs")
assert "web_search" in tools
assert "web_fetch" in tools