mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-01 19:18:35 -04:00
Merge verified Odysseus fixes
This commit is contained in:
+3
-1
@@ -16,7 +16,8 @@ FROM python:3.14-slim
|
||||
# downloads, and serves from Docker installs.
|
||||
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
|
||||
# launch inside Docker.
|
||||
# nodejs/npm provide npx for the optional built-in Browser MCP server.
|
||||
# nodejs/npm provide npx for the built-in Browser MCP server.
|
||||
# chromium provides the actual browser binary used by that MCP server.
|
||||
# gosu lets the entrypoint drop privileges cleanly so signals still reach
|
||||
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -26,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
nodejs \
|
||||
npm \
|
||||
chromium \
|
||||
tmux \
|
||||
openssh-client \
|
||||
gosu \
|
||||
|
||||
@@ -32,6 +32,14 @@ the codebase, you are probably right to stay away.
|
||||
before the user request really starts. We need slimmer prompts, better tool
|
||||
selection, smaller default tool sets, and clearer guidance for models with
|
||||
4k/8k/16k context windows.
|
||||
- Local model speculative decoding support. For Odysseus-tuned local models,
|
||||
plan to ship or recommend a small same-tokenizer draft model when the serving
|
||||
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
|
||||
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
|
||||
DSpark conversion performed poorly. Treat this as a supported draft-model lane
|
||||
first; keep MTP-specific packaging as future work only when the architecture
|
||||
and runtime support are real. Judge this by time-to-success, tool correctness,
|
||||
grammar, and unchanged target output, not tokens/sec alone.
|
||||
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
||||
fetched pages, and memories should be treated as untrusted data. Keep testing
|
||||
whether models follow malicious instructions from those surfaces.
|
||||
|
||||
@@ -543,6 +543,12 @@ class SessionManager:
|
||||
"""Permanently delete a session and all its messages."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
try:
|
||||
from src.session_image_cleanup import cleanup_session_images
|
||||
cleanup_session_images(session_id, db=db)
|
||||
except Exception as e:
|
||||
logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}")
|
||||
|
||||
# Detach documents so they survive as orphans in the library
|
||||
db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
|
||||
{DbDocument.session_id: None}, synchronize_session=False
|
||||
|
||||
+369
-6
@@ -24,6 +24,7 @@ from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
@@ -129,20 +130,36 @@ def _mcp_owner_required(rows: list[dict] | None = None) -> bool:
|
||||
return _has_owner_scoped_accounts(rows)
|
||||
|
||||
|
||||
def _load_email_writing_style() -> str:
|
||||
"""Return the existing Settings > Email > Writing Style value."""
|
||||
def _load_email_writing_style(account: str | None = None) -> str:
|
||||
"""Return the saved Settings > Email > Writing Style value.
|
||||
|
||||
Prefer the selected account's style when one exists; fall back to the
|
||||
legacy global style so older installs keep behaving as before.
|
||||
"""
|
||||
try:
|
||||
settings_path = DATA_DIR / "settings.json"
|
||||
if not settings_path.exists():
|
||||
return ""
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
account_id = ""
|
||||
if account:
|
||||
try:
|
||||
cfg = _load_config(account)
|
||||
account_id = str(cfg.get("account_id") or account or "").strip()
|
||||
except Exception:
|
||||
account_id = str(account or "").strip()
|
||||
by_account = settings.get("email_writing_styles_by_account") or {}
|
||||
if account_id and isinstance(by_account, dict):
|
||||
style = by_account.get(account_id)
|
||||
if isinstance(style, str) and style.strip():
|
||||
return style.strip()
|
||||
return str(settings.get("email_writing_style") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _writing_style_guidance() -> str:
|
||||
style = _load_email_writing_style()
|
||||
def _writing_style_guidance(account: str | None = None) -> str:
|
||||
style = _load_email_writing_style(account)
|
||||
if not style:
|
||||
return (
|
||||
"No saved writing style is configured in Settings > Email > Writing Style. "
|
||||
@@ -509,6 +526,251 @@ def _decode_header(raw):
|
||||
return "".join(decoded)
|
||||
|
||||
|
||||
def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||
m = re.search(rb"UID\s+(\d+)", meta_b or b"")
|
||||
return m.group(1).decode("ascii", errors="ignore") if m else ""
|
||||
|
||||
|
||||
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
pieces = re.findall(r"<([^>]+)>", raw)
|
||||
if not pieces:
|
||||
pieces = [p.strip() for p in raw.split(",") if p.strip()]
|
||||
out: list[dict] = []
|
||||
seen = set()
|
||||
for piece in pieces:
|
||||
target = piece.strip().strip("<>").strip()
|
||||
if not target:
|
||||
continue
|
||||
parsed = urlparse(target)
|
||||
scheme = parsed.scheme.lower()
|
||||
key = target.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if scheme == "mailto":
|
||||
addr = unquote(parsed.path or "").strip()
|
||||
if not addr or "\r" in addr or "\n" in addr:
|
||||
continue
|
||||
query = parse_qs(parsed.query or "", keep_blank_values=True)
|
||||
subject = unquote((query.get("subject") or ["unsubscribe"])[0] or "unsubscribe")
|
||||
body = unquote((query.get("body") or ["unsubscribe"])[0] or "unsubscribe")
|
||||
subject = re.sub(r"[\r\n]+", " ", subject).strip() or "unsubscribe"
|
||||
body = re.sub(r"[\r\n]+", "\n", body).strip() or "unsubscribe"
|
||||
out.append({
|
||||
"kind": "mailto",
|
||||
"target": addr,
|
||||
"subject": subject[:200],
|
||||
"body": body[:1000],
|
||||
"executable": True,
|
||||
})
|
||||
elif scheme in {"http", "https"}:
|
||||
out.append({
|
||||
"kind": "url",
|
||||
"target": target,
|
||||
"executable": False,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _email_unsubscribe_candidate_from_msg(msg, uid: str, folder: str) -> dict | None:
|
||||
sender = _decode_header(msg.get("From", ""))
|
||||
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||
list_id = _decode_header(msg.get("List-Id", ""))
|
||||
precedence = (msg.get("Precedence") or "").strip().lower()
|
||||
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
|
||||
methods = _parse_list_unsubscribe_header(msg.get("List-Unsubscribe"))
|
||||
if not methods:
|
||||
return None
|
||||
reasons: list[str] = ["has unsubscribe header"]
|
||||
score = 45
|
||||
if list_id:
|
||||
score += 20
|
||||
reasons.append("mailing-list header")
|
||||
if precedence in {"bulk", "junk", "list"}:
|
||||
score += 20
|
||||
reasons.append(f"precedence={precedence}")
|
||||
if auto_submitted and auto_submitted != "no":
|
||||
score += 10
|
||||
reasons.append(f"auto-submitted={auto_submitted}")
|
||||
if re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", (subject or "").lower()):
|
||||
score += 10
|
||||
reasons.append("promotional subject")
|
||||
executable = [m for m in methods if m.get("executable")]
|
||||
return {
|
||||
"uid": str(uid),
|
||||
"folder": folder,
|
||||
"message_id": (msg.get("Message-ID") or "").strip(),
|
||||
"subject": subject,
|
||||
"from_name": sender_name or sender_addr,
|
||||
"from_address": sender_addr,
|
||||
"list_id": list_id,
|
||||
"score": min(score, 100),
|
||||
"reasons": reasons[:5],
|
||||
"methods": methods,
|
||||
"can_execute": bool(executable),
|
||||
"recommended_method": executable[0] if executable else methods[0],
|
||||
}
|
||||
|
||||
|
||||
def _unsubscribe_candidate_dedupe_key(candidate: dict) -> tuple[str, str, str]:
|
||||
list_id = str(candidate.get("list_id") or "").strip().lower()
|
||||
method = candidate.get("recommended_method") or {}
|
||||
method_kind = str(method.get("kind") or "").strip().lower()
|
||||
method_target = str(method.get("target") or "").strip().lower()
|
||||
sender = str(candidate.get("from_address") or "").strip().lower()
|
||||
if list_id:
|
||||
return ("list", list_id, method_target or sender)
|
||||
if method_target:
|
||||
return ("method", method_kind, method_target)
|
||||
return ("sender", sender, str(candidate.get("subject") or "").strip().lower())
|
||||
|
||||
|
||||
def _dedupe_unsubscribe_candidates(candidates: list[dict]) -> list[dict]:
|
||||
deduped: dict[tuple[str, str, str], dict] = {}
|
||||
for candidate in candidates or []:
|
||||
key = _unsubscribe_candidate_dedupe_key(candidate)
|
||||
existing = deduped.get(key)
|
||||
if not existing:
|
||||
copy = dict(candidate)
|
||||
copy["duplicate_count"] = 1
|
||||
copy["duplicate_uids"] = [str(candidate.get("uid") or "")]
|
||||
deduped[key] = copy
|
||||
continue
|
||||
existing["duplicate_count"] = int(existing.get("duplicate_count") or 1) + 1
|
||||
uid = str(candidate.get("uid") or "")
|
||||
if uid:
|
||||
existing.setdefault("duplicate_uids", []).append(uid)
|
||||
if int(candidate.get("score") or 0) > int(existing.get("score") or 0):
|
||||
keep_count = existing.get("duplicate_count")
|
||||
keep_uids = existing.get("duplicate_uids")
|
||||
replacement = dict(candidate)
|
||||
replacement["duplicate_count"] = keep_count
|
||||
replacement["duplicate_uids"] = keep_uids
|
||||
deduped[key] = replacement
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def _scan_unsubscribe_candidates(folder="INBOX", account=None, limit=25, max_scan=150) -> dict:
|
||||
limit = max(1, min(int(limit or 25), 100))
|
||||
max_scan = max(limit, min(int(max_scan or 150), 500))
|
||||
folder = folder or "INBOX"
|
||||
candidates: list[dict] = []
|
||||
conn = _imap_connect(account)
|
||||
try:
|
||||
status, _ = conn.select(_q(folder), readonly=True)
|
||||
if status != "OK":
|
||||
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
|
||||
status, data = conn.uid("SEARCH", None, "ALL")
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
|
||||
uids = []
|
||||
for raw_uid in data[0].split():
|
||||
try:
|
||||
uids.append(int(raw_uid))
|
||||
except Exception:
|
||||
continue
|
||||
uids = sorted(uids, reverse=True)[:max_scan]
|
||||
if not uids:
|
||||
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
|
||||
status, msg_data = conn.uid("FETCH", _b(",".join(str(u) for u in uids)), "(UID RFC822.HEADER)")
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
if status != "OK":
|
||||
return {"success": False, "error": "Failed to fetch email headers", "candidates": []}
|
||||
for item in msg_data or []:
|
||||
if not isinstance(item, tuple) or len(item) < 2:
|
||||
continue
|
||||
meta_b = item[0] if isinstance(item[0], bytes) else str(item[0]).encode()
|
||||
uid = _uid_from_fetch_meta(meta_b)
|
||||
if not uid:
|
||||
continue
|
||||
try:
|
||||
msg = email.message_from_bytes(item[1] or b"")
|
||||
except Exception:
|
||||
continue
|
||||
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
|
||||
if candidate:
|
||||
candidates.append(candidate)
|
||||
raw_total = len(candidates)
|
||||
candidates = _dedupe_unsubscribe_candidates(candidates)
|
||||
candidates.sort(key=lambda c: (int(c.get("score") or 0), int(c.get("duplicate_count") or 1), int(c.get("uid") or 0)), reverse=True)
|
||||
return {
|
||||
"success": True,
|
||||
"candidates": candidates[:limit],
|
||||
"total": len(candidates),
|
||||
"raw_total": raw_total,
|
||||
"scanned": len(uids),
|
||||
"folder": folder,
|
||||
"account": account or "",
|
||||
}
|
||||
|
||||
|
||||
def _unsubscribe_email(uid, folder="INBOX", account=None, method_index=0, allow_web=False) -> dict:
|
||||
uid = str(uid or "").strip()
|
||||
if not uid:
|
||||
return {"success": False, "error": "uid is required"}
|
||||
conn = _imap_connect(account)
|
||||
try:
|
||||
status, _ = conn.select(_q(folder), readonly=True)
|
||||
if status != "OK":
|
||||
return {"success": False, "error": f"Folder not found: {folder}"}
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(UID RFC822.HEADER)")
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
if status != "OK" or not msg_data:
|
||||
return {"success": False, "error": f"Email not found: {uid}"}
|
||||
raw_header = b""
|
||||
for item in msg_data or []:
|
||||
if isinstance(item, tuple) and len(item) >= 2:
|
||||
raw_header = item[1] or b""
|
||||
break
|
||||
msg = email.message_from_bytes(raw_header)
|
||||
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
|
||||
if not candidate:
|
||||
return {"success": False, "error": "No List-Unsubscribe header found"}
|
||||
methods = candidate.get("methods") or []
|
||||
method_index = int(method_index or 0)
|
||||
method = methods[method_index] if 0 <= method_index < len(methods) else (candidate.get("recommended_method") or methods[0])
|
||||
if method.get("kind") == "url":
|
||||
return {
|
||||
"success": False,
|
||||
"requires_browser": True,
|
||||
"url": method.get("target"),
|
||||
"candidate": candidate,
|
||||
"instructions": (
|
||||
"This unsubscribe is a web link. Ask the user for approval, then use the browser/web tool "
|
||||
"to open the exact URL and complete the unsubscribe page. Do not fetch unrelated links."
|
||||
),
|
||||
}
|
||||
if method.get("kind") != "mailto" or not method.get("executable"):
|
||||
return {"success": False, "error": "Unsupported unsubscribe method", "candidate": candidate}
|
||||
result = _send_email(
|
||||
to=method.get("target"),
|
||||
subject=method.get("subject") or "unsubscribe",
|
||||
body=method.get("body") or "unsubscribe",
|
||||
account=account,
|
||||
)
|
||||
if "error" in result:
|
||||
return {"success": False, "error": result["error"], "candidate": candidate}
|
||||
return {
|
||||
"success": True,
|
||||
"method": method,
|
||||
"candidate": candidate,
|
||||
"send_result": result,
|
||||
"pending": bool(result.get("pending")),
|
||||
}
|
||||
|
||||
|
||||
def _extract_text(msg):
|
||||
"""Extract plain text body from email message."""
|
||||
if msg.is_multipart():
|
||||
@@ -1546,8 +1808,7 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
||||
except Exception as exc:
|
||||
return {"error": f"AI reply helpers unavailable: {exc}"}
|
||||
|
||||
settings = _load_settings()
|
||||
style = settings.get("email_writing_style", "")
|
||||
style = _load_email_writing_style(account)
|
||||
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
|
||||
if style:
|
||||
system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}"
|
||||
@@ -1890,6 +2151,45 @@ async def list_tools() -> list[Tool]:
|
||||
"required": [],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="scan_email_unsubscribes",
|
||||
description=(
|
||||
"Scan recent email headers for likely spam/newsletter unsubscribe candidates. "
|
||||
"Returns reviewable candidates with UID, sender, subject, score, reasons, and "
|
||||
"List-Unsubscribe methods. This does not unsubscribe anything. For mailto "
|
||||
"methods, use unsubscribe_email after user approval. For web URL methods, use "
|
||||
"browser/web tools after user approval to open the exact URL and complete the page."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder": {"type": "string", "description": "IMAP folder to scan", "default": "INBOX"},
|
||||
"limit": {"type": "integer", "description": "Maximum candidates to return", "default": 25},
|
||||
"max_scan": {"type": "integer", "description": "How many newest messages to inspect", "default": 150},
|
||||
**ACCOUNT_PROP,
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="unsubscribe_email",
|
||||
description=(
|
||||
"Execute one approved unsubscribe action for an email UID. Supports safe mailto "
|
||||
"List-Unsubscribe directly. If the selected method is a web URL, this returns "
|
||||
"requires_browser with the exact URL; use browser/web tools only after user approval."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
|
||||
"folder": {"type": "string", "description": "IMAP folder", "default": "INBOX"},
|
||||
"method_index": {"type": "integer", "description": "Unsubscribe method index from scan_email_unsubscribes", "default": 0},
|
||||
"allow_web": {"type": "boolean", "description": "Return web unsubscribe URL instructions when the method is URL", "default": False},
|
||||
**ACCOUNT_PROP,
|
||||
},
|
||||
"required": ["uid"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="download_attachment",
|
||||
description=(
|
||||
@@ -2264,6 +2564,69 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
lines.append(line)
|
||||
return [TextContent(type="text", text="\n\n".join(lines))]
|
||||
|
||||
elif name == "scan_email_unsubscribes":
|
||||
try:
|
||||
result = _scan_unsubscribe_candidates(
|
||||
folder=arguments.get("folder", "INBOX"),
|
||||
account=acct,
|
||||
limit=arguments.get("limit", 25),
|
||||
max_scan=arguments.get("max_scan", 150),
|
||||
)
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Unsubscribe scan failed: {e}")]
|
||||
if not result.get("success"):
|
||||
return [TextContent(type="text", text=f"Unsubscribe scan failed: {result.get('error', 'unknown error')}")]
|
||||
candidates = result.get("candidates") or []
|
||||
if not candidates:
|
||||
return [TextContent(type="text", text=f"No unsubscribe candidates found in {result.get('scanned', 0)} recent emails.")]
|
||||
lines = [
|
||||
f"Found {len(candidates)} unsubscribe candidate(s) from {result.get('scanned', 0)} recent emails.",
|
||||
"Review these with the user before executing. Mailto methods can use unsubscribe_email; URL methods require browser/web tools after approval.\n",
|
||||
]
|
||||
for i, cand in enumerate(candidates, 1):
|
||||
lines.append(
|
||||
f"{i}. **{cand.get('subject') or '(no subject)'}**\n"
|
||||
f" From: {cand.get('from_name') or cand.get('from_address') or ''} ({cand.get('from_address') or ''})\n"
|
||||
f" UID: {cand.get('uid')} Folder: {cand.get('folder')}\n"
|
||||
f" Score: {cand.get('score')} Matching emails: {cand.get('duplicate_count', 1)} Reasons: {', '.join(cand.get('reasons') or [])}"
|
||||
)
|
||||
for j, method in enumerate(cand.get("methods") or []):
|
||||
if method.get("kind") == "mailto":
|
||||
lines.append(f" Method {j}: mailto {method.get('target')} (executable via unsubscribe_email)")
|
||||
elif method.get("kind") == "url":
|
||||
lines.append(f" Method {j}: web URL {method.get('target')} (use browser/web tools after approval)")
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
elif name == "unsubscribe_email":
|
||||
result = _unsubscribe_email(
|
||||
uid=arguments.get("uid"),
|
||||
folder=arguments.get("folder", "INBOX"),
|
||||
account=acct,
|
||||
method_index=arguments.get("method_index", 0),
|
||||
allow_web=bool(arguments.get("allow_web", False)),
|
||||
)
|
||||
if result.get("requires_browser"):
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
"Web unsubscribe requires browser/web navigation.\n"
|
||||
f"URL: {result.get('url')}\n"
|
||||
f"{result.get('instructions')}"
|
||||
),
|
||||
)]
|
||||
if not result.get("success"):
|
||||
return [TextContent(type="text", text=f"Unsubscribe failed: {result.get('error', 'unknown error')}")]
|
||||
method = result.get("method") or {}
|
||||
if result.get("pending"):
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Unsubscribe email staged for approval to {method.get('target')}. "
|
||||
"Nothing has been sent until the user approves the pending email."
|
||||
),
|
||||
)]
|
||||
return [TextContent(type="text", text=f"Unsubscribe email sent to {method.get('target')}.")]
|
||||
|
||||
elif name == "download_attachment":
|
||||
uid = arguments.get("uid")
|
||||
index = arguments.get("index")
|
||||
|
||||
@@ -81,6 +81,12 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
if not model_spec:
|
||||
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
||||
|
||||
try:
|
||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, model_type="image")
|
||||
except ValueError:
|
||||
_lower_model_spec = model_spec.lower()
|
||||
if not any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e")):
|
||||
raise
|
||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec)
|
||||
|
||||
is_gpt_image = "gpt-image" in model_id.lower()
|
||||
|
||||
+64
-13
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -56,6 +57,9 @@ def _is_casual_low_signal(text: str) -> bool:
|
||||
# the background work (extraction, auto-naming) silently never runs.
|
||||
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
|
||||
_BG_TASKS: set[asyncio.Task] = set()
|
||||
_INCOGNITO_CONTEXTS: dict[str, dict[str, Any]] = {}
|
||||
_INCOGNITO_CONTEXT_TTL_SECONDS = 6 * 60 * 60
|
||||
_INCOGNITO_CONTEXT_MAX_MESSAGES = 80
|
||||
|
||||
|
||||
def _spawn_bg(coro) -> asyncio.Task:
|
||||
@@ -66,6 +70,40 @@ def _spawn_bg(coro) -> asyncio.Task:
|
||||
return task
|
||||
|
||||
|
||||
def _prune_incognito_contexts(now: float | None = None):
|
||||
now = now or time.time()
|
||||
stale = [
|
||||
sid for sid, bundle in _INCOGNITO_CONTEXTS.items()
|
||||
if now - float(bundle.get("updated_at") or 0) > _INCOGNITO_CONTEXT_TTL_SECONDS
|
||||
]
|
||||
for sid in stale:
|
||||
_INCOGNITO_CONTEXTS.pop(sid, None)
|
||||
|
||||
|
||||
def _incognito_messages(session_id: str) -> list[dict[str, Any]]:
|
||||
_prune_incognito_contexts()
|
||||
bundle = _INCOGNITO_CONTEXTS.get(str(session_id or ""))
|
||||
if not bundle:
|
||||
return []
|
||||
return [dict(m) for m in bundle.get("messages", []) if isinstance(m, dict)]
|
||||
|
||||
|
||||
def _append_incognito_message(session_id: str, role: str, content: Any, metadata: dict | None = None):
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return
|
||||
_prune_incognito_contexts()
|
||||
bundle = _INCOGNITO_CONTEXTS.setdefault(sid, {"messages": [], "updated_at": time.time()})
|
||||
msg: dict[str, Any] = {"role": role, "content": content}
|
||||
if metadata:
|
||||
msg["metadata"] = dict(metadata)
|
||||
messages = bundle.setdefault("messages", [])
|
||||
messages.append(msg)
|
||||
if len(messages) > _INCOGNITO_CONTEXT_MAX_MESSAGES:
|
||||
del messages[:-_INCOGNITO_CONTEXT_MAX_MESSAGES]
|
||||
bundle["updated_at"] = time.time()
|
||||
|
||||
|
||||
# ── Data containers ────────────────────────────────────────────────────── #
|
||||
|
||||
@dataclass
|
||||
@@ -434,11 +472,12 @@ def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[
|
||||
|
||||
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
||||
"""Add user message to session history and update session name.
|
||||
In incognito mode, still add to in-memory history (for conversation context)
|
||||
but skip session name update (which would persist)."""
|
||||
Incognito messages must not mutate persistent session history, even in
|
||||
memory, because a later normal turn can persist the same session object."""
|
||||
if incognito:
|
||||
return
|
||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
|
||||
if not incognito:
|
||||
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
||||
|
||||
|
||||
@@ -668,8 +707,14 @@ async def build_chat_context(
|
||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||
)
|
||||
|
||||
# Add user message to history
|
||||
add_user_message(sess, chat_handler, preprocessed, incognito=incognito)
|
||||
# Add user message to history. Nobody/incognito uses a request-local
|
||||
# transcript store instead of session history so stale saved chats cannot
|
||||
# bleed into context and the turn is not persisted.
|
||||
if incognito:
|
||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
||||
else:
|
||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
||||
|
||||
# Fire events
|
||||
if not incognito:
|
||||
@@ -760,8 +805,10 @@ async def build_chat_context(
|
||||
if norm:
|
||||
sess.model = norm
|
||||
|
||||
# Build messages
|
||||
messages = preface + sess.get_context_messages()
|
||||
# Build messages. In Nobody/incognito mode, never read saved session
|
||||
# history: the session id may be a temporary wrapper or, in buggy clients, a
|
||||
# stale normal session id. Only the ephemeral incognito transcript is safe.
|
||||
messages = preface + (_incognito_messages(session_id) if incognito else sess.get_context_messages())
|
||||
|
||||
# Current date/time — injected as a standalone *user*-role context message
|
||||
# placed immediately before the latest user turn, NOT folded into the
|
||||
@@ -1027,7 +1074,12 @@ def save_assistant_response(
|
||||
tool_events: list = None,
|
||||
incognito: bool = False,
|
||||
):
|
||||
"""Add assistant response to session history. In incognito mode, keeps in-memory context but skips DB persistence."""
|
||||
"""Add assistant response to session history.
|
||||
|
||||
Incognito responses are intentionally not added to the session object. The
|
||||
session may later be saved by a normal turn, so "in-memory only" is not
|
||||
private enough.
|
||||
"""
|
||||
md = dict(last_metrics) if last_metrics else {}
|
||||
def _model_value(value) -> str:
|
||||
if value is None:
|
||||
@@ -1067,19 +1119,18 @@ def save_assistant_response(
|
||||
_content = _think_info["reply"]
|
||||
else:
|
||||
_content = full_response
|
||||
if incognito:
|
||||
_append_incognito_message(session_id, "assistant", _content, md)
|
||||
return None
|
||||
sess.add_message(ChatMessage("assistant", _content, metadata=md))
|
||||
|
||||
if not incognito:
|
||||
from core.database import update_session_last_accessed
|
||||
update_session_last_accessed(session_id)
|
||||
session_manager.save_sessions()
|
||||
|
||||
# Return the persisted message's DB id so the stream can wire it onto the
|
||||
# freshly-rendered bubble — lets the user edit/delete a just-streamed reply
|
||||
# without reloading. Incognito returns None: those messages are ephemeral,
|
||||
# so we don't hand out an edit/delete handle for them.
|
||||
if incognito:
|
||||
return None
|
||||
# without reloading.
|
||||
try:
|
||||
_last = sess.history[-1]
|
||||
_meta = getattr(_last, "metadata", None)
|
||||
|
||||
+309
-23
@@ -42,6 +42,7 @@ from routes.chat_helpers import (
|
||||
_enforce_chat_privileges,
|
||||
)
|
||||
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
||||
from src.image_model_ids import looks_like_image_generation_model
|
||||
from src.tool_policy import (
|
||||
WEB_TOOL_NAMES,
|
||||
build_effective_tool_policy,
|
||||
@@ -53,7 +54,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Track active streams for partial-save safety net
|
||||
_active_streams: Dict[str, dict] = {}
|
||||
_IMAGE_MODEL_PREFIXES = ("gpt-image", "dall-e", "chatgpt-image")
|
||||
|
||||
|
||||
def _stream_set(session_id: str, **fields) -> None:
|
||||
@@ -111,7 +111,8 @@ def _ensure_current_request_is_latest_user(messages: List[Dict[str, Any]], curre
|
||||
_WEB_FOLLOWUP_RE = re.compile(
|
||||
r"^\s*(?:(?:can|could|would|will)\s+you\s+)?"
|
||||
r"(?:check|try\s+again|look(?:\s+now|\s+it\s+up)?|search(?:\s+now|\s+online|\s+it)?|"
|
||||
r"do\s+it|again)\??\s*$",
|
||||
r"do\s+it|again|approved|approve(?:d)?|yes|ok(?:ay)?|proceed|go\s+ahead|"
|
||||
r"send(?:\s+it)?|submit(?:\s+it)?|email(?:\s+them|\s+it)?)\??\s*$",
|
||||
re.I,
|
||||
)
|
||||
_RECENT_WEB_CONTEXT_RE = re.compile(
|
||||
@@ -119,6 +120,26 @@ _RECENT_WEB_CONTEXT_RE = re.compile(
|
||||
r"price|current|latest|search|look\s+up|online)\b",
|
||||
re.I,
|
||||
)
|
||||
_RECENT_BROWSER_CONTEXT_RE = re.compile(
|
||||
r"\b(?:browser|browse|open\s+(?:the\s+)?(?:site|page|url|link)|click|"
|
||||
r"fill(?:\s+out)?|submit|send\s+(?:the\s+)?form|contact\s+form|web\s*form|"
|
||||
r"form\s+submission|playwright|automation)\b",
|
||||
re.I,
|
||||
)
|
||||
_BROWSER_MCP_TOOLS = {
|
||||
"mcp__builtin_browser__browser_navigate",
|
||||
"mcp__builtin_browser__browser_snapshot",
|
||||
"mcp__builtin_browser__browser_click",
|
||||
"mcp__builtin_browser__browser_type",
|
||||
"mcp__builtin_browser__browser_fill_form",
|
||||
"mcp__builtin_browser__browser_select_option",
|
||||
"mcp__builtin_browser__browser_press_key",
|
||||
"mcp__builtin_browser__browser_wait_for",
|
||||
"mcp__builtin_browser__browser_take_screenshot",
|
||||
"mcp__builtin_browser__browser_drag",
|
||||
"mcp__builtin_browser__browser_navigate_back",
|
||||
"mcp__builtin_browser__browser_close",
|
||||
}
|
||||
|
||||
|
||||
def _recent_session_text(sess, limit: int = 8, max_chars: int = 2000) -> str:
|
||||
@@ -141,6 +162,13 @@ def _is_contextual_web_followup(message: str, sess) -> bool:
|
||||
return bool(_RECENT_WEB_CONTEXT_RE.search(_recent_session_text(sess)))
|
||||
|
||||
|
||||
def _is_contextual_browser_followup(message: str, sess) -> bool:
|
||||
"""Treat short retry replies as browser tasks when recent context was forms/browser automation."""
|
||||
if not message or not _WEB_FOLLOWUP_RE.search(message):
|
||||
return False
|
||||
return bool(_RECENT_BROWSER_CONTEXT_RE.search(_recent_session_text(sess, limit=12, max_chars=4000)))
|
||||
|
||||
|
||||
def _resolve_request_workspace(request, raw_value) -> tuple:
|
||||
"""Resolve the posted workspace for this request: (workspace, rejected).
|
||||
|
||||
@@ -168,6 +196,46 @@ def _resolve_request_workspace(request, raw_value) -> tuple:
|
||||
return workspace, (requested if not workspace else "")
|
||||
|
||||
|
||||
_ABS_PATH_RE = re.compile(r"(?<!\S)(~?/[^\"'\s`<>]+)")
|
||||
_LOCAL_FILE_TASK_RE = re.compile(
|
||||
r"\b(?:file|folder|directory|path|workspace|repo|project|movie|video|"
|
||||
r"subtitle|subtitles|srt|vtt|ass|download|save|rename|move|copy|extract|"
|
||||
r"convert|ffmpeg|run|execute|open|read|inspect|fix|debug|test|build)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_workspace_from_message_path(request, message: str) -> tuple[str, str]:
|
||||
"""Auto-bind a workspace only when the user names an explicit safe path.
|
||||
|
||||
This is intentionally deterministic rather than LLM/RAG-driven: RAG can
|
||||
choose the tool family, but filesystem binding must not let a prompt infer
|
||||
or probe arbitrary host paths. For a file path, bind its parent directory.
|
||||
For a directory path, bind that directory.
|
||||
"""
|
||||
text = str(message or "")
|
||||
if not text or not _LOCAL_FILE_TASK_RE.search(text):
|
||||
return "", ""
|
||||
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
if not owner_is_admin_or_single_user(get_current_user(request)):
|
||||
return "", ""
|
||||
|
||||
from src.tool_execution import vet_workspace
|
||||
|
||||
for match in _ABS_PATH_RE.finditer(text):
|
||||
raw = match.group(1).rstrip(".,;:)]}")
|
||||
expanded = os.path.realpath(os.path.expanduser(raw))
|
||||
candidates = [expanded]
|
||||
if os.path.isfile(expanded):
|
||||
candidates.insert(0, os.path.dirname(expanded))
|
||||
for candidate in candidates:
|
||||
workspace = vet_workspace(candidate) or ""
|
||||
if workspace:
|
||||
return workspace, ""
|
||||
return "", ""
|
||||
|
||||
|
||||
def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
||||
if not session_url or not endpoint_base:
|
||||
return False
|
||||
@@ -243,7 +311,7 @@ def _is_image_generation_session(sess, owner: str | None = None) -> bool:
|
||||
models into the image-generation path.
|
||||
"""
|
||||
model = (getattr(sess, "model", "") or "").strip()
|
||||
if any(model.lower().startswith(prefix) for prefix in _IMAGE_MODEL_PREFIXES):
|
||||
if looks_like_image_generation_model(model):
|
||||
return True
|
||||
|
||||
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
|
||||
@@ -271,6 +339,29 @@ def _is_image_generation_session(sess, owner: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None = None) -> Optional[Dict[str, Any]]:
|
||||
"""Return the first attached image file that this owner can read."""
|
||||
upload_handler = getattr(chat_handler, "upload_handler", None)
|
||||
if not upload_handler:
|
||||
return None
|
||||
for att_id in att_ids or []:
|
||||
try:
|
||||
info = upload_handler.resolve_upload(att_id, owner=owner)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to resolve image edit upload %s", att_id, exc_info=e)
|
||||
continue
|
||||
if not info:
|
||||
continue
|
||||
name = info.get("name") or info.get("original_name") or info.get("id") or ""
|
||||
mime = info.get("mime", "")
|
||||
try:
|
||||
if upload_handler.is_image_file(name, mime):
|
||||
return info
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||
|
||||
@@ -382,8 +473,84 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
db.rollback()
|
||||
logger.warning("Failed to recover empty session model for %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _reconcile_selected_route_from_request(
|
||||
request: Request,
|
||||
sess,
|
||||
session_id: str,
|
||||
form_data,
|
||||
owner: str | None = None,
|
||||
) -> bool:
|
||||
"""Apply the model route the browser selected before streaming.
|
||||
|
||||
The frontend creates a pending chat first and only materializes it on first
|
||||
send. Startup/default-model refreshes can race with that UI state, so the
|
||||
stream request includes the route that was selected at click/send time.
|
||||
Trust only registered endpoint ids, or the session's existing endpoint URL.
|
||||
"""
|
||||
selected_model = str(form_data.get("selected_model") or "").strip()
|
||||
selected_endpoint_id = str(form_data.get("selected_endpoint_id") or "").strip()
|
||||
selected_endpoint_url = str(form_data.get("selected_endpoint_url") or "").strip()
|
||||
if not selected_model:
|
||||
return False
|
||||
|
||||
endpoint_url = ""
|
||||
headers = None
|
||||
if selected_endpoint_id or selected_endpoint_url:
|
||||
try:
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.endpoint_resolver import build_headers, normalize_base
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if selected_endpoint_id:
|
||||
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
|
||||
if owner:
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
|
||||
ep = None
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
if selected_endpoint_id or _session_url_matches_endpoint(selected_endpoint_url, cand.base_url or ""):
|
||||
ep = cand
|
||||
break
|
||||
if not ep:
|
||||
return False
|
||||
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
|
||||
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to resolve selected endpoint %s/%s for %s: %s", selected_endpoint_id, selected_endpoint_url, session_id, e)
|
||||
return False
|
||||
|
||||
if not endpoint_url:
|
||||
return False
|
||||
|
||||
if (
|
||||
selected_model == (getattr(sess, "model", "") or "")
|
||||
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
|
||||
):
|
||||
return False
|
||||
|
||||
sess.model = selected_model
|
||||
sess.endpoint_url = endpoint_url
|
||||
sess.headers = headers or {}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.model = selected_model
|
||||
db_session.endpoint_url = endpoint_url
|
||||
db_session.headers = sess.headers or {}
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
logger.info("Reconciled selected route for %s: model=%r endpoint=%s", session_id, selected_model, redact_url(endpoint_url))
|
||||
return True
|
||||
|
||||
|
||||
def _set_user_time_from_request(request: Request) -> None:
|
||||
@@ -565,9 +732,7 @@ def setup_chat_routes(
|
||||
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
|
||||
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
|
||||
incognito = str(form_data.get("incognito", "")).lower() == "true"
|
||||
# Plan mode is not part of the merge-ready UI. Ignore stale clients or
|
||||
# manual form posts that still send plan_mode=true.
|
||||
plan_mode = False
|
||||
plan_mode = str(form_data.get("plan_mode") or (body or {}).get("plan_mode") or "").lower() == "true"
|
||||
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
|
||||
# Workspace: confine the agent's file/shell tools to this folder.
|
||||
workspace, workspace_rejected = _resolve_request_workspace(
|
||||
@@ -589,6 +754,25 @@ def setup_chat_routes(
|
||||
# not chats we quietly promoted for a notes/calendar intent.
|
||||
user_requested_agent = (chat_mode == "agent")
|
||||
_search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
|
||||
_explicit_web_intent = False
|
||||
_explicit_browser_intent = False
|
||||
if isinstance(message, str):
|
||||
_msg_l = message.lower()
|
||||
_explicit_web_intent = bool(re.search(
|
||||
r"\b(search|look\s*up|lookup|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
|
||||
_msg_l,
|
||||
))
|
||||
_explicit_browser_intent = bool(re.search(
|
||||
r"\b(browser|browse|open\s+(?:the\s+)?(?:site|page|url|link)|"
|
||||
r"click|fill(?:\s+out)?|submit|send\s+(?:the\s+)?form|"
|
||||
r"contact\s+form|web\s*form|form\s+submission)\b",
|
||||
_msg_l,
|
||||
))
|
||||
_allow_browser_for_web_turn = bool(
|
||||
_explicit_browser_intent
|
||||
or _explicit_web_intent
|
||||
or _search_enabled
|
||||
)
|
||||
# Intent auto-escalation: if the user is clearly asking the assistant
|
||||
# to create a todo, reminder, or calendar event, promote chat → agent
|
||||
# for this turn so the LLM has access to manage_notes / manage_calendar.
|
||||
@@ -598,9 +782,13 @@ def setup_chat_routes(
|
||||
# shell disabled).
|
||||
auto_escalated = False
|
||||
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
||||
_workspace_agent_intent = False
|
||||
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}
|
||||
if _workspace_agent_intent:
|
||||
allow_bash = "true"
|
||||
logger.info(
|
||||
"chat→agent auto-escalation: category=%s reason=%s",
|
||||
_tool_intent.category,
|
||||
@@ -610,6 +798,10 @@ def setup_chat_routes(
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
logger.info("chat→agent auto-escalation: search enabled")
|
||||
elif chat_mode == "chat" and _explicit_web_intent:
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
logger.info("chat→agent auto-escalation: explicit web intent")
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
||||
|
||||
@@ -688,6 +880,7 @@ def setup_chat_routes(
|
||||
_verify_session_owner(request, session)
|
||||
sess = session_manager.get_session(session)
|
||||
owner = effective_user(request)
|
||||
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
|
||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
# Issue #587: picker shows a model from the endpoint cache but
|
||||
@@ -711,11 +904,28 @@ def setup_chat_routes(
|
||||
_tool_intent = ToolIntent(True, "web", "contextual web lookup follow-up")
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
_workspace_agent_intent = False
|
||||
logger.info(
|
||||
"chat→agent auto-escalation: category=%s reason=%s",
|
||||
_tool_intent.category,
|
||||
_tool_intent.reason,
|
||||
)
|
||||
if isinstance(message, str) and _is_contextual_browser_followup(message, sess):
|
||||
_explicit_browser_intent = True
|
||||
if chat_mode == "chat":
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
_workspace_agent_intent = False
|
||||
logger.info("chat→agent auto-escalation: contextual browser/form follow-up")
|
||||
if not workspace and isinstance(message, str):
|
||||
_auto_workspace, _ = _resolve_workspace_from_message_path(request, message)
|
||||
if _auto_workspace:
|
||||
workspace = _auto_workspace
|
||||
chat_mode = "agent"
|
||||
auto_escalated = True
|
||||
_workspace_agent_intent = True
|
||||
allow_bash = "true"
|
||||
logger.info("chat→agent auto-escalation: explicit path workspace=%s", workspace)
|
||||
except SessionNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except (ValueError, ValidationError):
|
||||
@@ -750,7 +960,12 @@ def setup_chat_routes(
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse attachments JSON, ignoring attachments", exc_info=e)
|
||||
|
||||
image_generation_session = _is_image_generation_session(sess, owner=effective_user(request))
|
||||
no_memory = str(form_data.get("no_memory", "")).lower() == "true"
|
||||
if image_generation_session:
|
||||
no_memory = True
|
||||
use_rag = "false"
|
||||
search_context = None
|
||||
pre_context_tool_policy = build_effective_tool_policy(
|
||||
last_user_message=message,
|
||||
)
|
||||
@@ -879,7 +1094,7 @@ def setup_chat_routes(
|
||||
# explicitly enable it.
|
||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||
disabled_tools.add("bash")
|
||||
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
|
||||
_explicit_web_intent = _explicit_web_intent or bool(_tool_intent and _tool_intent.category == "web")
|
||||
if is_web_search_explicitly_denied(allow_web_search) or not _search_enabled:
|
||||
disabled_tools.update(WEB_TOOL_NAMES)
|
||||
if _explicit_web_intent:
|
||||
@@ -893,7 +1108,7 @@ def setup_chat_routes(
|
||||
"create_document", "edit_document", "update_document",
|
||||
"send_email", "reply_to_email",
|
||||
"manage_notes", "manage_calendar", "manage_tasks",
|
||||
"api_call", "builtin_browser",
|
||||
"api_call",
|
||||
})
|
||||
if _search_enabled:
|
||||
disabled_tools.difference_update(WEB_TOOL_NAMES)
|
||||
@@ -909,6 +1124,11 @@ def setup_chat_routes(
|
||||
"manage_memory", # persistent memory store
|
||||
"search_chats", # past chat history
|
||||
"manage_skills", # skill presets tied to user
|
||||
"create_session",
|
||||
"list_sessions",
|
||||
"manage_session",
|
||||
"send_to_session",
|
||||
"chat_with_model",
|
||||
})
|
||||
|
||||
# Active email reader open → strip the tools that let the agent drift
|
||||
@@ -935,7 +1155,7 @@ def setup_chat_routes(
|
||||
if not _privs.get("can_use_bash", True):
|
||||
disabled_tools.update({"bash", "python", "read_file", "write_file"})
|
||||
if not _privs.get("can_use_browser", True):
|
||||
disabled_tools.add("builtin_browser")
|
||||
disabled_tools.update(_BROWSER_MCP_TOOLS)
|
||||
if not _privs.get("can_use_documents", True):
|
||||
disabled_tools.update({"create_document", "edit_document", "update_document", "suggest_document"})
|
||||
if not _privs.get("can_generate_images", True):
|
||||
@@ -958,10 +1178,12 @@ def setup_chat_routes(
|
||||
# the heavy "do things on the computer" tools — otherwise the model
|
||||
# tries to shell out for a request that never needed it, then fails
|
||||
# (and looks broken when the shell is disabled).
|
||||
if auto_escalated:
|
||||
if auto_escalated and not _workspace_agent_intent:
|
||||
disabled_tools.update({
|
||||
"bash", "python", "read_file", "write_file", "builtin_browser",
|
||||
"bash", "python", "read_file", "write_file",
|
||||
})
|
||||
if not _allow_browser_for_web_turn:
|
||||
disabled_tools.update(_BROWSER_MCP_TOOLS)
|
||||
|
||||
# Disable document tools in compare sessions — they break the pane UI
|
||||
if sess.name and sess.name.startswith("[CMP]"):
|
||||
@@ -1195,7 +1417,7 @@ def setup_chat_routes(
|
||||
_model_info["character_name"] = ctx.preset.character_name
|
||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
||||
|
||||
if _is_image_generation_session(sess, owner=_user):
|
||||
if image_generation_session:
|
||||
from src.settings import get_setting
|
||||
if tool_policy.blocks("generate_image"):
|
||||
_blocked_msg = tool_policy.reason_for("generate_image")
|
||||
@@ -1208,26 +1430,85 @@ def setup_chat_routes(
|
||||
yield "data: [DONE]\n\n"
|
||||
_active_streams.pop(session, None)
|
||||
return
|
||||
from src.ai_interaction import do_generate_image
|
||||
from src.ai_interaction import do_edit_image, do_generate_image
|
||||
_user_msg = message or ""
|
||||
yield f'data: {json.dumps({"type": "tool_start", "tool": "generate_image", "command": _user_msg[:100]})}\n\n'
|
||||
_image_upload = _first_image_attachment(chat_handler, att_ids, owner=_user)
|
||||
_image_tool_name = "edit_image" if _image_upload else "generate_image"
|
||||
yield f'data: {json.dumps({"type": "tool_start", "tool": _image_tool_name, "command": _user_msg[:100]})}\n\n'
|
||||
yield ": heartbeat\n\n"
|
||||
_img_result = await do_generate_image(f"{_user_msg}\n{sess.model}", session, owner=_user)
|
||||
_progress_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async def _image_progress_callback(progress: Dict[str, Any]):
|
||||
try:
|
||||
_progress_queue.put_nowait(progress)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _image_upload:
|
||||
_img_task = asyncio.create_task(do_edit_image(
|
||||
_user_msg,
|
||||
_image_upload.get("path", ""),
|
||||
model_spec=sess.model,
|
||||
session_id=session,
|
||||
owner=_user,
|
||||
size="1024x1024",
|
||||
progress_callback=_image_progress_callback,
|
||||
))
|
||||
else:
|
||||
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user))
|
||||
_img_started = time.time()
|
||||
_img_tick = 0
|
||||
while not _img_task.done():
|
||||
try:
|
||||
_progress = await asyncio.wait_for(_progress_queue.get(), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
_progress = None
|
||||
_img_tick += 1
|
||||
_elapsed = int(time.time() - _img_started)
|
||||
_label = "Editing image" if _image_upload else "Generating image"
|
||||
yield ": image generation still running\n\n"
|
||||
_progress_data = {"type": "tool_progress", "tool": _image_tool_name, "message": f"{_label}… {_elapsed}s", "elapsed": _elapsed, "tick": _img_tick}
|
||||
if isinstance(_progress, dict) and _progress.get("total"):
|
||||
_step = int(_progress.get("step") or 0)
|
||||
_total = int(_progress.get("total") or 0)
|
||||
_percent = _progress.get("percent")
|
||||
_progress_data.update({
|
||||
"step": _step,
|
||||
"total": _total,
|
||||
"percent": _percent,
|
||||
"message": f"{_label}… {_step}/{_total}",
|
||||
})
|
||||
yield f'data: {json.dumps(_progress_data)}\n\n'
|
||||
_img_result = await _img_task
|
||||
_img_output = _img_result.get("results", _img_result.get("error", ""))
|
||||
_img_tool_data = {"type": "tool_output", "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
||||
_img_tool_data = {"type": "tool_output", "tool": _image_tool_name, "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
||||
for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
||||
if _k in _img_result:
|
||||
_img_tool_data[_k] = _img_result[_k]
|
||||
if _image_upload:
|
||||
_img_tool_data["source_image"] = {
|
||||
"id": _image_upload.get("id"),
|
||||
"name": _image_upload.get("name") or _image_upload.get("original_name"),
|
||||
}
|
||||
yield f'data: {json.dumps(_img_tool_data)}\n\n'
|
||||
if _img_result.get("image_url"):
|
||||
_img_event = {"type": "generated_image", "url": _img_result.get("image_url")}
|
||||
for _k in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
||||
if _img_result.get(_k):
|
||||
_img_event[_k] = _img_result[_k]
|
||||
yield f'data: {json.dumps(_img_event)}\n\n'
|
||||
_desc = _img_result.get("results", _img_result.get("error", "Image generation complete"))
|
||||
full_response = _desc
|
||||
yield f'data: {json.dumps({"delta": _desc})}\n\n'
|
||||
# Save to session history
|
||||
if not incognito:
|
||||
_ev = {"round": 1, "tool": "generate_image", "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
||||
_ev = {"round": 1, "tool": _image_tool_name, "command": _user_msg[:100], "output": _img_output, "exit_code": 0 if "error" not in _img_result else 1}
|
||||
for _ek in ("image_url", "image_id", "image_prompt", "image_model", "image_size", "image_quality"):
|
||||
if _img_result.get(_ek):
|
||||
_ev[_ek] = _img_result[_ek]
|
||||
if _image_upload:
|
||||
_ev["source_image_id"] = _image_upload.get("id")
|
||||
_ev["source_image_name"] = _image_upload.get("name") or _image_upload.get("original_name")
|
||||
sess.add_message(ChatMessage("assistant", full_response, metadata={"tool_events": [_ev], "model": sess.model}))
|
||||
session_manager.save_sessions()
|
||||
yield f'data: {json.dumps({"type": "metrics", "data": {"total_time": 0}})}\n\n'
|
||||
@@ -1292,8 +1573,10 @@ def setup_chat_routes(
|
||||
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
|
||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||
if ctx.context_length and last_metrics.get("input_tokens"):
|
||||
pct = min(round((last_metrics["input_tokens"] / ctx.context_length) * 100, 1), 100.0)
|
||||
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
|
||||
last_metrics["request_context_tokens"] = request_context_tokens
|
||||
if ctx.context_length and request_context_tokens:
|
||||
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
|
||||
last_metrics["context_percent"] = pct
|
||||
last_metrics["context_length"] = ctx.context_length
|
||||
# The frontend reads `tokens_per_second`; the raw usage event
|
||||
@@ -1326,6 +1609,7 @@ def setup_chat_routes(
|
||||
"input_tokens": _est_in,
|
||||
"output_tokens": _est_out,
|
||||
"tokens_per_second": _tps,
|
||||
"request_context_tokens": _est_in,
|
||||
"context_percent": _ctx_pct,
|
||||
"context_length": ctx.context_length,
|
||||
"model": _actual_model or _answered_by or _requested_model,
|
||||
@@ -1360,7 +1644,7 @@ def setup_chat_routes(
|
||||
_stream_set(session, status="done")
|
||||
yield chunk
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
if full_response:
|
||||
if full_response and not incognito:
|
||||
logger.info("Client disconnected mid-stream (chat mode) for session %s, saving partial (%d chars)", session, len(full_response))
|
||||
_stopped_content, _stopped_md = clean_thinking_for_save(
|
||||
full_response,
|
||||
@@ -1371,7 +1655,6 @@ def setup_chat_routes(
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
|
||||
if not incognito:
|
||||
session_manager.save_sessions()
|
||||
raise
|
||||
finally:
|
||||
@@ -1405,6 +1688,10 @@ def setup_chat_routes(
|
||||
_forced_tools = None
|
||||
if _search_enabled:
|
||||
_forced_tools = set(WEB_TOOL_NAMES)
|
||||
if _explicit_browser_intent:
|
||||
_forced_tools |= set(_BROWSER_MCP_TOOLS)
|
||||
elif _explicit_browser_intent:
|
||||
_forced_tools = set(_BROWSER_MCP_TOOLS)
|
||||
|
||||
async for chunk in stream_agent_loop(
|
||||
sess.endpoint_url,
|
||||
@@ -1529,7 +1816,7 @@ def setup_chat_routes(
|
||||
# outer finally from running and left _active_streams
|
||||
# with a stale entry).
|
||||
try:
|
||||
if full_response:
|
||||
if full_response and not incognito:
|
||||
logger.info("Client disconnected mid-stream for session %s, saving partial response (%d chars)", session, len(full_response))
|
||||
_stopped_content2, _stopped_md2 = clean_thinking_for_save(
|
||||
full_response,
|
||||
@@ -1540,7 +1827,6 @@ def setup_chat_routes(
|
||||
},
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
|
||||
if not incognito:
|
||||
session_manager.save_sessions()
|
||||
except Exception:
|
||||
logger.exception("Failed to save partial response on disconnect (session %s)", session)
|
||||
|
||||
@@ -463,14 +463,22 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" if sz == 0 and os.path.isdir(snap):",
|
||||
" sz2, nf2, ic2 = snapshot_size()",
|
||||
" sz, nf, ic = sz2, nf2, ic or ic2",
|
||||
" is_diffusion = False; gguf_files = []",
|
||||
" is_video = bool(re.search(r'(?i)(^|/)Lightricks/LTX-|(^|/)LTX[-_/]|video|text-to-video|image-to-video', rid))",
|
||||
" is_diffusion = is_video; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', rid)); gguf_files = []",
|
||||
" if os.path.isdir(snap):",
|
||||
" for sd in os.listdir(snap):",
|
||||
" sf = os.path.join(snap, sd)",
|
||||
" if not os.path.isdir(sf): continue",
|
||||
" if os.path.exists(os.path.join(sf, 'model_index.json')): is_diffusion = True",
|
||||
" if os.path.exists(os.path.join(sf, 'adapter_config.json')) or os.path.exists(os.path.join(sf, 'adapter_model.safetensors')): is_adapter = True",
|
||||
" for _root, _dirs, _fns in safe_walk(sf):",
|
||||
" for _fn in _fns:",
|
||||
" _lfn = _fn.lower()",
|
||||
" if _lfn.endswith('.safetensors') and re.search(r'(?i)(ltx|video|upscaler)', _lfn): is_video = True; is_diffusion = True",
|
||||
" if _lfn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in _lfn:",
|
||||
" is_adapter = True",
|
||||
" for f in collect_ggufs(sf): f['rel_path'] = sd + '/' + f['rel_path']; gguf_files.append(f)",
|
||||
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
||||
" models.append({'repo_id':rid,'size_bytes':sz,'nb_files':nf,'has_incomplete':ic,'path':cache,'is_diffusion':is_diffusion,'is_video':is_video,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
||||
"def hf_cache_paths():",
|
||||
" candidates = []",
|
||||
" def add(p):",
|
||||
@@ -505,11 +513,12 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" fp = os.path.join(p, d)",
|
||||
" if not os.path.isdir(fp) or os.path.islink(fp) or not safe_path(fp): continue",
|
||||
" if d in seen: continue",
|
||||
" is_model = False; gguf_files = []",
|
||||
" is_model = False; is_adapter = bool(re.search(r'(?i)(lora|adapter|peft|qlora|control[-_]?lora|diffusion[-_]?lora)', d)); gguf_files = []",
|
||||
" for root, dirs, fns in safe_walk(fp):",
|
||||
" for fn in fns:",
|
||||
" if fn.lower().endswith('.gguf'): is_model = True",
|
||||
" elif fn == 'config.json' or fn.endswith('.safetensors') or fn.endswith('.bin'): is_model = True",
|
||||
" if fn in ('adapter_config.json','adapter_model.safetensors','pytorch_lora_weights.safetensors') or 'lora' in fn.lower(): is_adapter = True",
|
||||
" if is_model: break",
|
||||
" if not is_model: continue",
|
||||
" gguf_files = collect_ggufs(fp)",
|
||||
@@ -520,7 +529,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" try: nf += 1; sz += os.path.getsize(os.path.join(dp, fn))",
|
||||
" except Exception: pass",
|
||||
" is_diff = os.path.exists(os.path.join(fp, 'model_index.json'))",
|
||||
" models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
||||
" models.append({'repo_id':d,'size_bytes':sz,'nb_files':nf,'has_incomplete':False,'path':p,'is_local_dir':True,'is_diffusion':is_diff,'is_adapter':is_adapter,'is_gguf':bool(gguf_files),'gguf_files':gguf_files})",
|
||||
"def parse_size(num, unit):",
|
||||
" try: n = float(num)",
|
||||
" except Exception: return 0",
|
||||
@@ -1320,6 +1329,26 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||
),
|
||||
(
|
||||
r"mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['\"]?mflux",
|
||||
"MLX image serving requires mflux on this Apple Silicon server.",
|
||||
[{"label": "install mflux in Cookbook Dependencies", "op": "dependency", "package": "mflux"}],
|
||||
),
|
||||
(
|
||||
r"mlx-lama-swift|odysseus-mlx-inpaint|mlx-lama-serve|LaMa / MI-GAN MLX inpainting models require",
|
||||
"LaMa / MI-GAN MLX inpainting requires an Odysseus-compatible mlx-lama-swift bridge on this Apple Silicon server.",
|
||||
[{"label": "build mlx-lama-swift bridge and put odysseus-mlx-inpaint or mlx-lama-serve on PATH", "op": "dependency", "package": "mlx_lama_swift"}],
|
||||
),
|
||||
(
|
||||
r"mlx-ddcolor-swift|odysseus-mlx-colorize|mlx-ddcolor-serve|DDColor MLX models require",
|
||||
"DDColor MLX colorization requires an Odysseus-compatible mlx-ddcolor-swift bridge on this Apple Silicon server.",
|
||||
[{"label": "build mlx-ddcolor-swift bridge and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH", "op": "dependency", "package": "mlx_ddcolor_swift"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
@@ -1358,9 +1387,9 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||
),
|
||||
(
|
||||
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers",
|
||||
"Diffusion serving requires PyTorch and diffusers.",
|
||||
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}],
|
||||
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||
),
|
||||
(
|
||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||
|
||||
+184
-25
@@ -73,6 +73,23 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
||||
)
|
||||
|
||||
|
||||
def _append_mlx_image_server_script(runner_lines: list[str]) -> None:
|
||||
"""Write the MLX image API helper next to the tmux runner on remote hosts."""
|
||||
script_path = Path(__file__).resolve().parents[1] / "scripts" / "mlx_image_server.py"
|
||||
try:
|
||||
script = script_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read mlx_image_server.py: %s", e)
|
||||
runner_lines.append('echo "ERROR: Odysseus could not prepare the MLX image server helper."')
|
||||
runner_lines.append('ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
return
|
||||
runner_lines.append('mkdir -p scripts')
|
||||
runner_lines.append("cat > scripts/mlx_image_server.py <<'PY'")
|
||||
runner_lines.extend(script.splitlines())
|
||||
runner_lines.append("PY")
|
||||
runner_lines.append('chmod +x scripts/mlx_image_server.py 2>/dev/null || true')
|
||||
|
||||
|
||||
def _venv_root_from_serve_cmd(cmd: str) -> str:
|
||||
"""Best-effort venv root from an absolute venv python in a serve command."""
|
||||
try:
|
||||
@@ -492,6 +509,11 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
@@ -530,9 +552,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||
),
|
||||
(
|
||||
r"No module named 'torch'|No module named torch|No module named 'diffusers'|No module named diffusers",
|
||||
"Diffusion serving requires PyTorch and diffusers.",
|
||||
[{"label": "install diffusers[torch] in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch]"}],
|
||||
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||
),
|
||||
(
|
||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||
@@ -1435,6 +1457,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"status": "downloading" if m["has_incomplete"] else "ready",
|
||||
"path": m.get("path", ""),
|
||||
"is_diffusion": m.get("is_diffusion", False),
|
||||
"is_video": m.get("is_video", False),
|
||||
"is_adapter": m.get("is_adapter", False),
|
||||
}
|
||||
if m.get("is_local_dir"):
|
||||
entry["is_local_dir"] = True
|
||||
@@ -1460,6 +1484,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.settings import load_settings, save_settings
|
||||
|
||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
@@ -1477,6 +1502,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# Friendly display name from repo_id
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = f"{short_name} (image)"
|
||||
pinned_models = [req.repo_id] if req.repo_id else []
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -1486,7 +1512,16 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "image"
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "manual"
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
if settings.get("image_gen_enabled") is not True:
|
||||
settings["image_gen_enabled"] = True
|
||||
save_settings(settings)
|
||||
logger.info(f"Updated existing image endpoint: {base_url}")
|
||||
return existing.id
|
||||
|
||||
@@ -1498,9 +1533,18 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="image",
|
||||
endpoint_kind="local",
|
||||
model_refresh_mode="manual",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
settings["image_gen_enabled"] = True
|
||||
if not settings.get("image_model"):
|
||||
settings["image_model"] = req.repo_id
|
||||
save_settings(settings)
|
||||
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
@@ -2356,24 +2400,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append('fi')
|
||||
elif "sglang.launch_server" in req.cmd:
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
||||
runner_lines.append('if ! command -v sglang &>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: SGLang is not installed."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('elif ! ODYSSEUS_SGLANG_IMPORT_ERROR="$(python3 -c "import sglang" 2>&1)"; then')
|
||||
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import."')
|
||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
elif "mlx_lm.server" in req.cmd:
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$(python3 -c "import mlx_lm" 2>&1)"; then')
|
||||
runner_lines.append(' echo "ERROR: MLX LM is not installed."')
|
||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
|
||||
runner_lines.append(' ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('ODYSSEUS_SGLANG_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import shlex, sys')
|
||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||
runner_lines.append('py = "python3"')
|
||||
@@ -2384,6 +2412,36 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append('print(py)')
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append('if ! "$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" &>/dev/null; then')
|
||||
runner_lines.append(' if ! command -v sglang &>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: SGLang is not installed."')
|
||||
runner_lines.append(' else')
|
||||
runner_lines.append(' echo "ERROR: SGLang is installed but failed to import in the launch Python."')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' ODYSSEUS_SGLANG_IMPORT_ERROR="$("$ODYSSEUS_SGLANG_CMD_PY" -c "import sglang" 2>&1)"')
|
||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_SGLANG_IMPORT_ERROR"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
elif "mlx_lm.server" in req.cmd:
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||
runner_lines.append('ODYSSEUS_MLX_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import shlex, sys')
|
||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||
runner_lines.append('py = "python3"')
|
||||
runner_lines.append('for i, part in enumerate(parts):')
|
||||
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
|
||||
runner_lines.append(' py = part')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append('print(py)')
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append('if ! ODYSSEUS_MLX_IMPORT_ERROR="$("$ODYSSEUS_MLX_CMD_PY" -c "import mlx_lm" 2>&1)"; then')
|
||||
runner_lines.append(' echo "ERROR: MLX LM is not installed in the launch Python: $ODYSSEUS_MLX_CMD_PY"')
|
||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_MLX_IMPORT_ERROR"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('if [ -z "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
|
||||
runner_lines.append(' ODYSSEUS_SERVE_CMD="$("$ODYSSEUS_MLX_CMD_PY" - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import json, os, shlex, sys')
|
||||
runner_lines.append('from pathlib import Path')
|
||||
@@ -2474,10 +2532,111 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append('fi')
|
||||
elif "scripts/mlx_image_server.py" in req.cmd or ".mlx_image_server.py" in req.cmd:
|
||||
_append_mlx_image_server_script(runner_lines)
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
|
||||
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||
runner_lines.append('ODYSSEUS_MLX_IMAGE_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import shlex, sys')
|
||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||
runner_lines.append('py = "python3"')
|
||||
runner_lines.append('for part in parts:')
|
||||
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
|
||||
runner_lines.append(' py = part')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append('print(py)')
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append('ODYSSEUS_MLX_IMAGE_BIN_DIR="$(dirname "$ODYSSEUS_MLX_IMAGE_CMD_PY" 2>/dev/null || true)"')
|
||||
runner_lines.append('if [ -n "$ODYSSEUS_MLX_IMAGE_BIN_DIR" ]; then export PATH="$ODYSSEUS_MLX_IMAGE_BIN_DIR:$PATH"; fi')
|
||||
runner_lines.append('if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import fastapi, uvicorn, multipart" >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: MLX image serving requires FastAPI + uvicorn + python-multipart in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY. Install the MLX image dependencies in Cookbook Dependencies."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append('ODYSSEUS_MLX_IMAGE_MODEL="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import shlex, sys')
|
||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||
runner_lines.append('model = ""')
|
||||
runner_lines.append('for i, part in enumerate(parts):')
|
||||
runner_lines.append(' if part == "--model" and i + 1 < len(parts):')
|
||||
runner_lines.append(' model = parts[i + 1]')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append('print(model)')
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append('if printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi hidream; then')
|
||||
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import mlx, mlx_vlm, transformers, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: HiDream MLX serving needs the model requirements in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart mlx mlx-vlm \'transformers>=4.57.0,<6.0\' huggingface_hub safetensors numpy pillow tqdm sentencepiece hf_transfer"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -qi boogu; then')
|
||||
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import boogu_image_mlx, mlx, huggingface_hub, safetensors, numpy, PIL" >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: Boogu MLX serving needs boogu-image-mlx in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "ddcolor"; then')
|
||||
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: DDColor MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if ! command -v odysseus-mlx-colorize >/dev/null 2>&1 && ! command -v mlx-ddcolor-serve >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: DDColor MLX serving requires the Odysseus mlx-ddcolor-swift bridge on PATH: odysseus-mlx-colorize or mlx-ddcolor-serve."')
|
||||
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' ODYSSEUS_DDCOLOR_BIN="$(command -v odysseus-mlx-colorize 2>/dev/null || command -v mlx-ddcolor-serve 2>/dev/null || true)"')
|
||||
runner_lines.append(' if [ -n "$ODYSSEUS_DDCOLOR_BIN" ]; then')
|
||||
runner_lines.append(' ODYSSEUS_DDCOLOR_DIR="$(dirname "$ODYSSEUS_DDCOLOR_BIN")"')
|
||||
runner_lines.append(' if [ ! -f "$ODYSSEUS_DDCOLOR_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_DDCOLOR_DIR/default.metallib" ]; then')
|
||||
runner_lines.append(' echo "ERROR: DDColor MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
|
||||
runner_lines.append(' echo "Run the DDColor MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif printf "%s" "$ODYSSEUS_MLX_IMAGE_MODEL" | grep -Eqi "mi-gan|migan|lama"; then')
|
||||
runner_lines.append(' if ! "$ODYSSEUS_MLX_IMAGE_CMD_PY" -c "import PIL" >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving needs Pillow in the launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' if ! command -v odysseus-mlx-inpaint >/dev/null 2>&1 && ! command -v mlx-lama-serve >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving requires the Odysseus mlx-lama-swift bridge on PATH: odysseus-mlx-inpaint or mlx-lama-serve."')
|
||||
runner_lines.append(' echo "Build it from swift/odysseus-mlx-image-bridge in Cookbook Dependencies."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' ODYSSEUS_INPAINT_BIN="$(command -v odysseus-mlx-inpaint 2>/dev/null || command -v mlx-lama-serve 2>/dev/null || true)"')
|
||||
runner_lines.append(' if [ -n "$ODYSSEUS_INPAINT_BIN" ]; then')
|
||||
runner_lines.append(' ODYSSEUS_INPAINT_DIR="$(dirname "$ODYSSEUS_INPAINT_BIN")"')
|
||||
runner_lines.append(' if [ ! -f "$ODYSSEUS_INPAINT_DIR/mlx.metallib" ] && [ ! -f "$ODYSSEUS_INPAINT_DIR/default.metallib" ]; then')
|
||||
runner_lines.append(' echo "ERROR: LaMa / MI-GAN MLX serving found the Swift runner, but mlx.metallib/default.metallib is missing next to it."')
|
||||
runner_lines.append(' echo "Run the LaMa / MI-GAN MLX image editing dependency install again; it copies mlx.metallib from the launch Python MLX package."')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append('elif ! command -v mflux-generate >/dev/null 2>&1 && ! command -v mflux-generate-qwen >/dev/null 2>&1; then')
|
||||
runner_lines.append(' echo "ERROR: mflux-compatible MLX image serving requires mflux-generate or mflux-generate-qwen in PATH for launch Python: $ODYSSEUS_MLX_IMAGE_CMD_PY."')
|
||||
runner_lines.append(' echo "Install with: $ODYSSEUS_MLX_IMAGE_CMD_PY -m pip install -U mflux fastapi uvicorn python-multipart"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
elif "scripts/diffusion_server.py" in req.cmd or ".diffusion_server.py" in req.cmd:
|
||||
runner_lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
||||
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$(python3 -c "import torch, diffusers" 2>&1)"; then')
|
||||
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + diffusers."')
|
||||
runner_lines.append(f"ODYSSEUS_SERVE_CMD='{_bash_squote(req.cmd)}'")
|
||||
runner_lines.append('ODYSSEUS_DIFFUSION_CMD_PY="$(python3 - "$ODYSSEUS_SERVE_CMD" <<\'PY\'')
|
||||
runner_lines.append('import shlex, sys')
|
||||
runner_lines.append('parts = shlex.split(sys.argv[1])')
|
||||
runner_lines.append('py = "python3"')
|
||||
runner_lines.append('for part in parts:')
|
||||
runner_lines.append(' if part.endswith("/bin/python") or part.endswith("/bin/python3") or "/bin/python3." in part:')
|
||||
runner_lines.append(' py = part')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append('print(py)')
|
||||
runner_lines.append('PY')
|
||||
runner_lines.append(')"')
|
||||
runner_lines.append('if ! ODYSSEUS_DIFFUSION_IMPORT_ERROR="$("$ODYSSEUS_DIFFUSION_CMD_PY" -c "import torch, torchvision, diffusers" 2>&1)"; then')
|
||||
runner_lines.append(' echo "ERROR: Diffusion serving requires PyTorch + Torchvision + diffusers in the launch Python: $ODYSSEUS_DIFFUSION_CMD_PY."')
|
||||
runner_lines.append(' printf "%s\\n" "$ODYSSEUS_DIFFUSION_IMPORT_ERROR"')
|
||||
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
|
||||
runner_lines.append('fi')
|
||||
@@ -2588,8 +2747,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# endpoint; any other real model serve (i.e. not a pip-install task) gets
|
||||
# a local LLM endpoint pointed at its /v1.
|
||||
endpoint_id = None
|
||||
is_diffusion = "diffusion_server.py" in req.cmd
|
||||
if is_diffusion:
|
||||
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
|
||||
if is_image_endpoint:
|
||||
endpoint_id = _auto_register_image_endpoint(req, remote)
|
||||
elif not is_pip_install:
|
||||
endpoint_id = _auto_register_llm_endpoint(req, remote)
|
||||
@@ -2605,7 +2764,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# if N != 0 within the watch window, delete the endpoint we just
|
||||
# created. Skipped for diffusion (different image-endpoint cleanup
|
||||
# path) and pip-install tasks (no endpoint to drop).
|
||||
if endpoint_id and not is_diffusion and not is_pip_install:
|
||||
if endpoint_id and not is_image_endpoint and not is_pip_install:
|
||||
asyncio.create_task(_serve_crash_watchdog(
|
||||
endpoint_id=endpoint_id,
|
||||
session_id=session_id,
|
||||
|
||||
@@ -246,6 +246,7 @@ import re as _re_reply
|
||||
# serves replies and summaries (any fenced final-output block).
|
||||
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
||||
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
||||
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
|
||||
|
||||
|
||||
def _extract_reply(text: str) -> str:
|
||||
@@ -272,6 +273,7 @@ def _extract_reply(text: str) -> str:
|
||||
# Drop any stray/duplicate marker tokens, then strip think markup.
|
||||
t = _REPLY_OPEN_RE.sub("", t)
|
||||
t = _REPLY_CLOSE_RE.sub("", t)
|
||||
t = _REPLY_ROLE_MARKER_RE.sub("", t)
|
||||
return _strip_think(t).strip()
|
||||
|
||||
|
||||
|
||||
+342
-19
@@ -100,6 +100,272 @@ def _owner_for_email_account(account_id: str | None) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _email_date_only(value: str | None):
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value[:10], "%Y-%m-%d").date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_AUTO_REPLY_KEYS = {
|
||||
"email_auto_reply",
|
||||
"email_auto_reply_start",
|
||||
"email_auto_reply_end",
|
||||
"email_auto_reply_subject",
|
||||
"email_auto_reply_message",
|
||||
"email_auto_reply_cooldown",
|
||||
"email_auto_reply_scope",
|
||||
"email_auto_reply_account_id",
|
||||
"email_auto_reply_exclude_automated",
|
||||
"email_auto_reply_pause_notifications",
|
||||
"email_auto_reply_enabled_at",
|
||||
}
|
||||
|
||||
|
||||
def _effective_settings_for_email_account(settings: dict, account_id: str | None) -> dict:
|
||||
"""Overlay per-account auto-reply settings onto global settings.
|
||||
|
||||
Other automation toggles remain global. This lets each mailbox have its own
|
||||
away reply while preserving existing installs that only have global keys.
|
||||
"""
|
||||
effective = dict(settings or {})
|
||||
key = str(account_id or "").strip()
|
||||
by_account = effective.get("email_auto_reply_by_account") or {}
|
||||
account_cfg = by_account.get(key) if key and isinstance(by_account, dict) else None
|
||||
if isinstance(account_cfg, dict):
|
||||
for k in _AUTO_REPLY_KEYS:
|
||||
if k in account_cfg:
|
||||
effective[k] = account_cfg[k]
|
||||
return effective
|
||||
|
||||
|
||||
def _away_reply_active(settings: dict, account_id: str | None) -> bool:
|
||||
if not settings.get("email_auto_reply", False):
|
||||
return False
|
||||
|
||||
scope = str(settings.get("email_auto_reply_scope") or "all").strip().lower()
|
||||
if scope == "account":
|
||||
selected = str(settings.get("email_auto_reply_account_id") or "").strip()
|
||||
if selected and selected != str(account_id or ""):
|
||||
return False
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
start = _email_date_only(settings.get("email_auto_reply_start"))
|
||||
end = _email_date_only(settings.get("email_auto_reply_end"))
|
||||
if start and today < start:
|
||||
return False
|
||||
if end and today > end:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _message_after_away_enabled(settings: dict, msg) -> bool:
|
||||
enabled_at = (settings.get("email_auto_reply_enabled_at") or "").strip()
|
||||
if not enabled_at:
|
||||
# Existing installs may already have the toggle on before this feature
|
||||
# existed. Do not back-reply old mail until the user saves/toggles it.
|
||||
return False
|
||||
try:
|
||||
enabled_dt = datetime.fromisoformat(enabled_at.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
msg_dt = email.utils.parsedate_to_datetime(msg.get("Date", ""))
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
if enabled_dt.tzinfo and not msg_dt.tzinfo:
|
||||
msg_dt = msg_dt.replace(tzinfo=enabled_dt.tzinfo)
|
||||
elif msg_dt.tzinfo and not enabled_dt.tzinfo:
|
||||
enabled_dt = enabled_dt.replace(tzinfo=msg_dt.tzinfo)
|
||||
except Exception:
|
||||
pass
|
||||
return msg_dt >= enabled_dt
|
||||
|
||||
|
||||
def _away_reply_period_key(settings: dict) -> str:
|
||||
start = (settings.get("email_auto_reply_start") or "").strip()
|
||||
end = (settings.get("email_auto_reply_end") or "").strip()
|
||||
return f"{start or '*'}..{end or '*'}"
|
||||
|
||||
|
||||
def _away_reply_cooldown_seconds(settings: dict) -> int | None:
|
||||
raw = str(settings.get("email_auto_reply_cooldown") or "period").strip().lower()
|
||||
if raw == "1d":
|
||||
return 24 * 60 * 60
|
||||
if raw == "3d":
|
||||
return 3 * 24 * 60 * 60
|
||||
if raw == "7d":
|
||||
return 7 * 24 * 60 * 60
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_away_reply_table():
|
||||
import sqlite3 as _sql3
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_away_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT DEFAULT '',
|
||||
account_id TEXT DEFAULT '',
|
||||
message_id TEXT DEFAULT '',
|
||||
sender_addr TEXT DEFAULT '',
|
||||
subject TEXT DEFAULT '',
|
||||
period_key TEXT DEFAULT '',
|
||||
sent_at TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_msg ON email_away_replies(owner, account_id, message_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_email_away_sender ON email_away_replies(owner, account_id, sender_addr, sent_at)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _sender_is_automated(msg, sender_addr: str) -> bool:
|
||||
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
|
||||
if auto_submitted and auto_submitted != "no":
|
||||
return True
|
||||
precedence = (msg.get("Precedence") or "").strip().lower()
|
||||
if precedence in {"bulk", "junk", "list"}:
|
||||
return True
|
||||
if msg.get("List-Id") or msg.get("List-Unsubscribe"):
|
||||
return True
|
||||
local = (sender_addr or "").split("@", 1)[0].lower()
|
||||
return local in {
|
||||
"no-reply", "noreply", "do-not-reply", "donotreply",
|
||||
"notification", "notifications", "automated", "mailer-daemon",
|
||||
"postmaster",
|
||||
}
|
||||
|
||||
|
||||
def _away_reply_already_sent(settings: dict, account_owner: str, account_id: str | None,
|
||||
message_id: str, sender_addr: str) -> bool:
|
||||
import sqlite3 as _sql3
|
||||
_ensure_away_reply_table()
|
||||
owner = account_owner or ""
|
||||
aid = account_id or ""
|
||||
sender = (sender_addr or "").strip().lower()
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND message_id=? LIMIT 1",
|
||||
(owner, aid, message_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return True
|
||||
|
||||
cooldown = _away_reply_cooldown_seconds(settings)
|
||||
if cooldown is None:
|
||||
period_key = _away_reply_period_key(settings)
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? AND period_key=? LIMIT 1",
|
||||
(owner, aid, sender, period_key),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
since = datetime.utcnow().timestamp() - cooldown
|
||||
rows = conn.execute(
|
||||
"SELECT sent_at FROM email_away_replies WHERE owner=? AND account_id=? AND sender_addr=? ORDER BY sent_at DESC LIMIT 5",
|
||||
(owner, aid, sender),
|
||||
).fetchall()
|
||||
for (sent_at,) in rows:
|
||||
try:
|
||||
if datetime.fromisoformat(sent_at).timestamp() >= since:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _record_away_reply(settings: dict, account_owner: str, account_id: str | None,
|
||||
message_id: str, sender_addr: str, subject: str):
|
||||
import sqlite3 as _sql3
|
||||
_ensure_away_reply_table()
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO email_away_replies
|
||||
(owner, account_id, message_id, sender_addr, subject, period_key, sent_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
account_owner or "",
|
||||
account_id or "",
|
||||
message_id,
|
||||
(sender_addr or "").strip().lower(),
|
||||
subject or "",
|
||||
_away_reply_period_key(settings),
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _send_away_reply(settings: dict, account_owner: str, account_id: str | None,
|
||||
msg, message_id: str, sender: str, subject: str):
|
||||
sender_name, sender_addr = email.utils.parseaddr(sender or "")
|
||||
sender_addr = (sender_addr or "").strip()
|
||||
if not sender_addr:
|
||||
return False, "missing sender"
|
||||
|
||||
cfg = _get_email_config(account_id, owner=account_owner)
|
||||
from_addr = (cfg.get("from_address") or cfg.get("smtp_user") or "").strip()
|
||||
if not from_addr:
|
||||
return False, "missing from address"
|
||||
if sender_addr.lower() == from_addr.lower():
|
||||
return False, "self mail"
|
||||
if settings.get("email_auto_reply_exclude_automated", True) and _sender_is_automated(msg, sender_addr):
|
||||
return False, "automated sender"
|
||||
if _away_reply_already_sent(settings, account_owner, account_id, message_id, sender_addr):
|
||||
return False, "already sent"
|
||||
|
||||
body = (settings.get("email_auto_reply_message") or "").strip()
|
||||
if not body:
|
||||
body = "Thanks for your email. I'm away and may be slower to reply."
|
||||
|
||||
subject_template = (settings.get("email_auto_reply_subject") or "(Away) {subject}").strip()
|
||||
if subject_template:
|
||||
original_subject = subject or ""
|
||||
reply_subject = (
|
||||
subject_template
|
||||
.replace("{subject}", original_subject)
|
||||
.replace("{original_subject}", original_subject)
|
||||
).strip() or "Re:"
|
||||
else:
|
||||
reply_subject = subject or ""
|
||||
if not reply_subject.lower().lstrip().startswith("re:"):
|
||||
reply_subject = f"Re: {reply_subject}" if reply_subject else "Re:"
|
||||
|
||||
outer = MIMEMultipart("alternative")
|
||||
display = cfg.get("display_name") or ""
|
||||
outer["From"] = email.utils.formataddr((display, from_addr)) if display else from_addr
|
||||
outer["To"] = email.utils.formataddr((sender_name, sender_addr)) if sender_name else sender_addr
|
||||
outer["Subject"] = reply_subject
|
||||
outer["Date"] = email.utils.formatdate(localtime=False)
|
||||
outer["Message-ID"] = email.utils.make_msgid()
|
||||
outer["Auto-Submitted"] = "auto-replied"
|
||||
outer["X-Auto-Response-Suppress"] = "All"
|
||||
if message_id:
|
||||
outer["In-Reply-To"] = message_id
|
||||
refs = (msg.get("References") or "").strip()
|
||||
outer["References"] = f"{refs} {message_id}".strip()
|
||||
outer.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
_send_smtp_message(cfg, from_addr, [sender_addr], outer.as_string())
|
||||
_record_away_reply(settings, account_owner, account_id, message_id, sender_addr, subject)
|
||||
return True, sender_addr
|
||||
|
||||
|
||||
# ── Routes ──
|
||||
|
||||
async def _emit_progress(progress_cb, message: str):
|
||||
@@ -125,9 +391,10 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
|
||||
settings = _load_settings()
|
||||
prev = {k: settings.get(k, False) for k in
|
||||
("email_auto_summarize", "email_auto_reply", "email_auto_tag",
|
||||
"email_auto_spam", "email_auto_calendar")}
|
||||
"email_auto_spam", "email_auto_calendar", "_email_auto_reply_draft_only")}
|
||||
settings["email_auto_summarize"] = bool(do_summary)
|
||||
settings["email_auto_reply"] = bool(do_reply)
|
||||
settings["_email_auto_reply_draft_only"] = bool(do_reply)
|
||||
settings["email_auto_tag"] = bool(do_tag)
|
||||
settings["email_auto_spam"] = bool(do_spam)
|
||||
settings["email_auto_calendar"] = bool(do_calendar)
|
||||
@@ -142,6 +409,9 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
|
||||
finally:
|
||||
s2 = _load_settings()
|
||||
for k, v in prev.items():
|
||||
if v is None and k.startswith("_"):
|
||||
s2.pop(k, None)
|
||||
else:
|
||||
s2[k] = v
|
||||
_save_settings(s2)
|
||||
|
||||
@@ -176,7 +446,7 @@ def _latest_inbox_fallback_uids(conn, reconnect):
|
||||
return [], reconnect()
|
||||
|
||||
|
||||
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
|
||||
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None, away_only: bool = False) -> str:
|
||||
"""Single pass of the auto-summarize/reply scan.
|
||||
|
||||
When account_id is None, iterates over every enabled account in
|
||||
@@ -208,6 +478,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
|
||||
account_id=(ids[0] if ids else None),
|
||||
max_process=max_process,
|
||||
progress_cb=progress_cb,
|
||||
away_only=away_only,
|
||||
)
|
||||
outs = []
|
||||
for idx, aid in enumerate(ids, start=1):
|
||||
@@ -218,6 +489,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
|
||||
account_id=aid,
|
||||
max_process=max_process,
|
||||
progress_cb=progress_cb,
|
||||
away_only=away_only,
|
||||
)
|
||||
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
|
||||
except Exception as e:
|
||||
@@ -229,23 +501,32 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
|
||||
account_id=account_id,
|
||||
max_process=max_process,
|
||||
progress_cb=progress_cb,
|
||||
away_only=away_only,
|
||||
)
|
||||
|
||||
|
||||
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
|
||||
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None, away_only: bool = False) -> str:
|
||||
"""Single pass of the auto-summarize/reply scan for ONE account.
|
||||
Reads current settings flags."""
|
||||
import asyncio
|
||||
import sqlite3 as _sql3
|
||||
from src.llm_core import _uses_max_completion_tokens
|
||||
|
||||
settings = _load_settings()
|
||||
settings = _effective_settings_for_email_account(_load_settings(), account_id)
|
||||
auto_sum = settings.get("email_auto_summarize", False)
|
||||
auto_reply = settings.get("email_auto_reply", False)
|
||||
auto_reply_draft = bool(auto_reply and settings.get("_email_auto_reply_draft_only", False))
|
||||
auto_reply_away = bool(auto_reply and not auto_reply_draft and _away_reply_active(settings, account_id))
|
||||
auto_tag = settings.get("email_auto_tag", False)
|
||||
auto_spam = settings.get("email_auto_spam", False)
|
||||
auto_cal = settings.get("email_auto_calendar", False)
|
||||
if not auto_sum and not auto_reply and not auto_tag and not auto_spam and not auto_cal:
|
||||
if away_only:
|
||||
auto_sum = False
|
||||
auto_reply_draft = False
|
||||
auto_tag = False
|
||||
auto_spam = False
|
||||
auto_cal = False
|
||||
if not auto_sum and not auto_reply_draft and not auto_reply_away and not auto_tag and not auto_spam and not auto_cal:
|
||||
return "Nothing to do"
|
||||
|
||||
# Owner of the account being processed. All calendar + mailbox reads/writes
|
||||
@@ -304,11 +585,11 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner)
|
||||
_sum_existing = {r[0] for r in _c.execute(
|
||||
_sum_existing = set() if away_only else {r[0] for r in _c.execute(
|
||||
f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}",
|
||||
_cache_owner_params,
|
||||
).fetchall()}
|
||||
_reply_existing = {r[0] for r in _c.execute(
|
||||
_reply_existing = set() if away_only else {r[0] for r in _c.execute(
|
||||
f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}",
|
||||
_cache_owner_params,
|
||||
).fetchall()}
|
||||
@@ -325,7 +606,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
).fetchall()}
|
||||
else:
|
||||
_tag_existing = set()
|
||||
_cal_existing = {r[0] for r in _c.execute(
|
||||
_cal_existing = set() if away_only else {r[0] for r in _c.execute(
|
||||
f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}",
|
||||
_cache_owner_params,
|
||||
).fetchall()} if auto_cal else set()
|
||||
@@ -351,11 +632,20 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
if auto_spam and not spam_folder:
|
||||
logger.warning("Auto-spam enabled but no Junk/Spam folder detected — will classify but not move")
|
||||
|
||||
needs_llm = bool(auto_sum or auto_reply_draft or auto_tag or auto_spam or auto_cal)
|
||||
if needs_llm:
|
||||
task_candidates = resolve_task_candidates(owner=account_owner)
|
||||
if not task_candidates:
|
||||
return "No model configured"
|
||||
url, model, headers = task_candidates[0]
|
||||
else:
|
||||
url, model, headers = None, "", None
|
||||
|
||||
by_account_styles = settings.get("email_writing_styles_by_account") or {}
|
||||
writing_style = ""
|
||||
if account_id and isinstance(by_account_styles, dict):
|
||||
writing_style = str(by_account_styles.get(str(account_id)) or "")
|
||||
if not writing_style:
|
||||
writing_style = settings.get("email_writing_style", "")
|
||||
processed = 0
|
||||
already_cached = 0
|
||||
@@ -366,12 +656,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_events_created = 0
|
||||
_replies_drafted = 0
|
||||
_reply_failed = 0
|
||||
_away_replies_sent = 0
|
||||
_away_replies_skipped = 0
|
||||
_away_replies_failed = 0
|
||||
_detail_lines = []
|
||||
_current_folder = "INBOX"
|
||||
# Calendar extraction is sequential and each row can involve a model
|
||||
# call plus a calendar write. Keep the scheduled calendar-only pass
|
||||
# below the 5-minute action budget instead of timing out mid-run.
|
||||
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply and not auto_tag and not auto_spam) else 5
|
||||
_default_max_process = 3 if (auto_cal and not auto_sum and not auto_reply_draft and not auto_reply_away and not auto_tag and not auto_spam) else 5
|
||||
try:
|
||||
_max_process = max(1, int(max_process)) if max_process is not None else _default_max_process
|
||||
except Exception:
|
||||
@@ -402,10 +695,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
seed = f"{_folder}|{uid_str}|{msg.get('From','')}|{msg.get('Date','')}|{msg.get('Subject','')}"
|
||||
message_id = f"<synth-{_hl.sha256(seed.encode()).hexdigest()[:16]}@local>"
|
||||
no_msgid += 1
|
||||
need_sum = auto_sum and message_id not in _sum_existing
|
||||
need_reply = auto_reply and message_id not in _reply_existing
|
||||
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
|
||||
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
|
||||
# Only check urgency on INBOX (received mail), not Sent
|
||||
# Skip messages that are themselves urgency alerts, or that
|
||||
# we sent to ourselves — otherwise the alert loop re-flags
|
||||
@@ -422,17 +711,45 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
except Exception:
|
||||
_from_addr_only = ""
|
||||
_is_self_mail = bool(_self_self_addr) and _from_addr_only.lower() == _self_self_addr
|
||||
need_sum = auto_sum and message_id not in _sum_existing
|
||||
need_reply = auto_reply_draft and message_id not in _reply_existing
|
||||
need_away_reply = bool(
|
||||
auto_reply_away
|
||||
and _folder.upper() == "INBOX"
|
||||
and not _is_self_mail
|
||||
and (away_only or _message_after_away_enabled(settings, msg))
|
||||
and not _away_reply_already_sent(settings, account_owner, account_id, message_id, _from_addr_only)
|
||||
)
|
||||
need_class = (auto_tag or auto_spam) and message_id not in _tag_existing
|
||||
need_cal = bool(settings.get("email_auto_calendar", False)) and message_id not in _cal_existing
|
||||
need_urgent = (auto_urgent and message_id not in _urgent_existing
|
||||
and not _folder.lower().startswith("sent")
|
||||
and "sent" not in _folder.lower()
|
||||
and not _is_alert_echo
|
||||
and not _is_self_mail)
|
||||
if not need_sum and not need_reply and not need_class and not need_cal and not need_urgent:
|
||||
if not need_sum and not need_reply and not need_away_reply and not need_class and not need_cal and not need_urgent:
|
||||
already_cached += 1
|
||||
await _emit_progress(progress_cb, f"Checked {examined}/{len(uid_list)} · {already_cached} already cached")
|
||||
continue
|
||||
subject = _decode_header(msg.get("Subject", ""))
|
||||
sender = _decode_header(msg.get("From", ""))
|
||||
if need_away_reply:
|
||||
try:
|
||||
sent_away, away_detail = _send_away_reply(
|
||||
settings, account_owner, account_id, msg, message_id, sender, subject
|
||||
)
|
||||
if sent_away:
|
||||
_away_replies_sent += 1
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"away reply · {_folder}#{_uid_text} · {subject or '(no subject)'} — {away_detail}")
|
||||
else:
|
||||
_away_replies_skipped += 1
|
||||
logger.info(f"Away reply skipped for uid={uid}: {away_detail}")
|
||||
except Exception as e:
|
||||
_away_replies_failed += 1
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"away reply failed · {_folder}#{_uid_text} · {subject or '(no subject)'}")
|
||||
logger.warning(f"Away reply {uid} failed: {e}")
|
||||
body = _extract_text(msg)
|
||||
# Pull text out of any PDFs / text attachments and append to
|
||||
# the body so summaries / replies can actually reason about
|
||||
@@ -454,7 +771,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
elif need_reply:
|
||||
if not body:
|
||||
body = subject
|
||||
elif (not body or len(body) < 100) and not att_text:
|
||||
elif not need_away_reply and (not body or len(body) < 100) and not att_text:
|
||||
too_short += 1
|
||||
continue
|
||||
# Augmented body sent to the LLM: original body + attachment text.
|
||||
@@ -993,7 +1310,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
# Build a clear status message
|
||||
ops = []
|
||||
if auto_sum: ops.append("summary")
|
||||
if auto_reply: ops.append("reply")
|
||||
if auto_reply_draft: ops.append("reply")
|
||||
if auto_reply_away: ops.append("away")
|
||||
if auto_tag: ops.append("tag")
|
||||
if auto_spam: ops.append("spam")
|
||||
ops_label = "/".join(ops) or "none"
|
||||
@@ -1002,10 +1320,14 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
parts.append(f"processed {processed} new")
|
||||
if auto_sum:
|
||||
parts.append(f"summarized {_summaries_created}")
|
||||
if auto_reply:
|
||||
if auto_reply_draft:
|
||||
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
||||
if _reply_failed:
|
||||
parts.append(f"{_reply_failed} reply failed")
|
||||
if auto_reply_away:
|
||||
parts.append(f"sent {_away_replies_sent} away repl" + ("y" if _away_replies_sent == 1 else "ies"))
|
||||
if _away_replies_failed:
|
||||
parts.append(f"{_away_replies_failed} away failed")
|
||||
if already_cached:
|
||||
parts.append(f"{already_cached} already cached")
|
||||
if too_short:
|
||||
@@ -1032,12 +1354,13 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
|
||||
|
||||
async def _auto_summarize_poller():
|
||||
"""Background loop kept for backward compatibility — calls _auto_summarize_pass every 60s.
|
||||
"""Background loop kept for backward compatibility — calls _auto_summarize_pass periodically.
|
||||
Newer setups should use scheduled tasks instead (summarize_emails, draft_email_replies)."""
|
||||
import asyncio as _asyncio
|
||||
while True:
|
||||
try:
|
||||
await _asyncio.sleep(1800)
|
||||
settings = _load_settings()
|
||||
await _asyncio.sleep(60 if settings.get("email_auto_reply", False) else 1800)
|
||||
await _auto_summarize_pass()
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-summarize poller crash: {e}")
|
||||
|
||||
+695
-30
@@ -26,6 +26,7 @@ import re
|
||||
import html
|
||||
import io
|
||||
import zipfile
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from html.parser import HTMLParser as _HTMLParser
|
||||
import logging
|
||||
import uuid
|
||||
@@ -89,6 +90,86 @@ def _google_oauth_imap_transport_allowed(port: int, starttls: bool) -> bool:
|
||||
def _google_oauth_smtp_transport_allowed(port: int, security: str) -> bool:
|
||||
return (port == 465 and security == "ssl") or (port == 587 and security == "starttls")
|
||||
|
||||
def _email_style_key(account_id: str | None) -> str:
|
||||
return str(account_id or "").strip()
|
||||
|
||||
|
||||
def _get_email_writing_style_for_account(settings: dict, account_id: str | None = None) -> str:
|
||||
key = _email_style_key(account_id)
|
||||
by_account = settings.get("email_writing_styles_by_account") or {}
|
||||
if key and isinstance(by_account, dict):
|
||||
val = by_account.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
return val
|
||||
return str(settings.get("email_writing_style") or "")
|
||||
|
||||
|
||||
def _set_email_writing_style_for_account(settings: dict, style: str, account_id: str | None = None) -> None:
|
||||
key = _email_style_key(account_id)
|
||||
style = str(style or "")
|
||||
if key:
|
||||
by_account = settings.get("email_writing_styles_by_account")
|
||||
if not isinstance(by_account, dict):
|
||||
by_account = {}
|
||||
by_account[key] = style
|
||||
settings["email_writing_styles_by_account"] = by_account
|
||||
return
|
||||
settings["email_writing_style"] = style
|
||||
|
||||
|
||||
_AUTO_REPLY_BOOL_KEYS = {
|
||||
"email_auto_reply",
|
||||
"email_auto_reply_exclude_automated",
|
||||
"email_auto_reply_pause_notifications",
|
||||
}
|
||||
_AUTO_REPLY_TEXT_KEYS = {
|
||||
"email_auto_reply_start",
|
||||
"email_auto_reply_end",
|
||||
"email_auto_reply_subject",
|
||||
"email_auto_reply_message",
|
||||
"email_auto_reply_cooldown",
|
||||
"email_auto_reply_scope",
|
||||
"email_auto_reply_account_id",
|
||||
"email_auto_reply_enabled_at",
|
||||
}
|
||||
_AUTO_REPLY_KEYS = _AUTO_REPLY_BOOL_KEYS | _AUTO_REPLY_TEXT_KEYS
|
||||
|
||||
|
||||
def _get_auto_reply_settings_for_account(settings: dict, account_id: str | None = None) -> dict:
|
||||
key = _email_style_key(account_id)
|
||||
out = {k: settings.get(k) for k in _AUTO_REPLY_KEYS if k in settings}
|
||||
by_account = settings.get("email_auto_reply_by_account") or {}
|
||||
if key and isinstance(by_account, dict) and isinstance(by_account.get(key), dict):
|
||||
out.update({k: v for k, v in by_account[key].items() if k in _AUTO_REPLY_KEYS})
|
||||
return out
|
||||
|
||||
|
||||
def _set_auto_reply_settings_for_account(settings: dict, data: dict, account_id: str | None = None) -> tuple[bool, bool]:
|
||||
key = _email_style_key(account_id)
|
||||
target = _get_auto_reply_settings_for_account(settings, account_id) if key else settings
|
||||
prev_auto_reply = bool(target.get("email_auto_reply", False))
|
||||
for name in _AUTO_REPLY_BOOL_KEYS:
|
||||
if name in data:
|
||||
target[name] = bool(data[name])
|
||||
for name in _AUTO_REPLY_TEXT_KEYS - {"email_auto_reply_enabled_at"}:
|
||||
if name in data:
|
||||
target[name] = str(data.get(name) or "").strip()
|
||||
if "email_auto_reply" in data:
|
||||
next_auto_reply = bool(target.get("email_auto_reply", False))
|
||||
if next_auto_reply and (not prev_auto_reply or not str(target.get("email_auto_reply_enabled_at") or "").strip()):
|
||||
target["email_auto_reply_enabled_at"] = datetime.utcnow().isoformat()
|
||||
elif not next_auto_reply:
|
||||
target.pop("email_auto_reply_enabled_at", None)
|
||||
if key:
|
||||
by_account = settings.get("email_auto_reply_by_account")
|
||||
if not isinstance(by_account, dict):
|
||||
by_account = {}
|
||||
by_account[key] = {k: target.get(k) for k in _AUTO_REPLY_KEYS if k in target}
|
||||
by_account[key]["email_auto_reply_account_id"] = key
|
||||
by_account[key]["email_auto_reply_scope"] = "account"
|
||||
settings["email_auto_reply_by_account"] = by_account
|
||||
return prev_auto_reply, bool(target.get("email_auto_reply", False))
|
||||
|
||||
|
||||
def _safe_attachment_zip_name(name: str, fallback: str) -> str:
|
||||
"""Return a zip entry filename without path traversal or empty names."""
|
||||
@@ -273,6 +354,25 @@ def _record_email_received_events(owner: str, account_id: str | None, folder: st
|
||||
for _ in new_keys[:50]:
|
||||
fire_event("email_received", owner)
|
||||
logger.info("Fired email_received for %d new message(s)", min(len(new_keys), 50))
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def _run_away_reply_check():
|
||||
try:
|
||||
from routes.email_pollers import _auto_summarize_pass
|
||||
result = await _auto_summarize_pass(
|
||||
days_back=1,
|
||||
account_id=account_id,
|
||||
max_process=min(max(len(new_keys), 1), 5),
|
||||
away_only=True,
|
||||
)
|
||||
logger.info("Auto away-reply pass after email_received account=%s: %s", account_id, result)
|
||||
except Exception:
|
||||
logger.warning("Auto away-reply pass after email_received failed", exc_info=True)
|
||||
|
||||
loop.create_task(_run_away_reply_check())
|
||||
except RuntimeError:
|
||||
logger.debug("No running event loop for immediate away-reply check")
|
||||
except Exception:
|
||||
logger.debug("email_received event detection skipped", exc_info=True)
|
||||
|
||||
@@ -383,6 +483,148 @@ def _uid_from_fetch_meta(meta_b: bytes) -> str:
|
||||
return m.group(1).decode() if m else ""
|
||||
|
||||
|
||||
def _parse_list_unsubscribe_header(value: str | None) -> list[dict]:
|
||||
"""Parse RFC List-Unsubscribe entries into safe reviewable actions.
|
||||
|
||||
We return mailto/http entries but only the mailto kind is executable by the
|
||||
first-pass Odysseus flow. HTTP unsubscribe links are useful evidence but
|
||||
often contain tracking tokens and should be opened manually unless/until we
|
||||
add a browser-confirmed flow.
|
||||
"""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
pieces = re.findall(r"<([^>]+)>", raw)
|
||||
if not pieces:
|
||||
pieces = [p.strip() for p in raw.split(",") if p.strip()]
|
||||
out: list[dict] = []
|
||||
seen = set()
|
||||
for piece in pieces:
|
||||
target = piece.strip().strip("<>").strip()
|
||||
if not target:
|
||||
continue
|
||||
parsed = urlparse(target)
|
||||
scheme = parsed.scheme.lower()
|
||||
key = target.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if scheme == "mailto":
|
||||
addr = unquote(parsed.path or "").strip()
|
||||
if not addr or "\r" in addr or "\n" in addr:
|
||||
continue
|
||||
query = parse_qs(parsed.query or "", keep_blank_values=True)
|
||||
subject = unquote((query.get("subject") or ["unsubscribe"])[0] or "unsubscribe")
|
||||
body = unquote((query.get("body") or ["unsubscribe"])[0] or "unsubscribe")
|
||||
subject = re.sub(r"[\r\n]+", " ", subject).strip() or "unsubscribe"
|
||||
body = re.sub(r"[\r\n]+", "\n", body).strip() or "unsubscribe"
|
||||
out.append({
|
||||
"kind": "mailto",
|
||||
"target": addr,
|
||||
"subject": subject[:200],
|
||||
"body": body[:1000],
|
||||
"executable": True,
|
||||
})
|
||||
elif scheme in {"http", "https"}:
|
||||
out.append({
|
||||
"kind": "url",
|
||||
"target": target,
|
||||
"executable": False,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _email_unsubscribe_candidate_from_msg(msg, uid: str, folder: str, *, spam_cached: dict | None = None) -> dict | None:
|
||||
sender = _decode_header(msg.get("From", ""))
|
||||
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||
list_id = _decode_header(msg.get("List-Id", ""))
|
||||
precedence = (msg.get("Precedence") or "").strip().lower()
|
||||
auto_submitted = (msg.get("Auto-Submitted") or "").strip().lower()
|
||||
methods = _parse_list_unsubscribe_header(msg.get("List-Unsubscribe"))
|
||||
has_unsub = bool(methods)
|
||||
reasons: list[str] = []
|
||||
score = 0
|
||||
if has_unsub:
|
||||
score += 45
|
||||
reasons.append("has unsubscribe header")
|
||||
if list_id:
|
||||
score += 20
|
||||
reasons.append("mailing-list header")
|
||||
if precedence in {"bulk", "junk", "list"}:
|
||||
score += 20
|
||||
reasons.append(f"precedence={precedence}")
|
||||
if auto_submitted and auto_submitted != "no":
|
||||
score += 10
|
||||
reasons.append(f"auto-submitted={auto_submitted}")
|
||||
if spam_cached and spam_cached.get("spam"):
|
||||
score += 35
|
||||
if spam_cached.get("reason"):
|
||||
reasons.append(str(spam_cached.get("reason")))
|
||||
else:
|
||||
reasons.append("previously classified as spam")
|
||||
subj_l = (subject or "").lower()
|
||||
if re.search(r"\b(unsubscribe|newsletter|sale|discount|offer|promo|limited time)\b", subj_l):
|
||||
score += 10
|
||||
reasons.append("promotional subject")
|
||||
executable = [m for m in methods if m.get("executable")]
|
||||
if score < 45 or not has_unsub:
|
||||
return None
|
||||
return {
|
||||
"uid": str(uid),
|
||||
"folder": folder,
|
||||
"message_id": (msg.get("Message-ID") or "").strip(),
|
||||
"subject": subject,
|
||||
"from_name": sender_name or sender_addr,
|
||||
"from_address": sender_addr,
|
||||
"list_id": list_id,
|
||||
"score": min(score, 100),
|
||||
"reasons": reasons[:5],
|
||||
"methods": methods,
|
||||
"can_execute": bool(executable),
|
||||
"recommended_method": executable[0] if executable else (methods[0] if methods else None),
|
||||
"spam_reason": (spam_cached or {}).get("reason") or "",
|
||||
}
|
||||
|
||||
|
||||
def _unsubscribe_candidate_dedupe_key(candidate: dict) -> tuple[str, str, str]:
|
||||
list_id = str(candidate.get("list_id") or "").strip().lower()
|
||||
method = candidate.get("recommended_method") or {}
|
||||
method_kind = str(method.get("kind") or "").strip().lower()
|
||||
method_target = str(method.get("target") or "").strip().lower()
|
||||
sender = str(candidate.get("from_address") or "").strip().lower()
|
||||
if list_id:
|
||||
return ("list", list_id, method_target or sender)
|
||||
if method_target:
|
||||
return ("method", method_kind, method_target)
|
||||
return ("sender", sender, str(candidate.get("subject") or "").strip().lower())
|
||||
|
||||
|
||||
def _dedupe_unsubscribe_candidates(candidates: list[dict]) -> list[dict]:
|
||||
deduped: dict[tuple[str, str, str], dict] = {}
|
||||
for candidate in candidates or []:
|
||||
key = _unsubscribe_candidate_dedupe_key(candidate)
|
||||
existing = deduped.get(key)
|
||||
if not existing:
|
||||
copy = dict(candidate)
|
||||
copy["duplicate_count"] = 1
|
||||
copy["duplicate_uids"] = [str(candidate.get("uid") or "")]
|
||||
deduped[key] = copy
|
||||
continue
|
||||
existing["duplicate_count"] = int(existing.get("duplicate_count") or 1) + 1
|
||||
uid = str(candidate.get("uid") or "")
|
||||
if uid:
|
||||
existing.setdefault("duplicate_uids", []).append(uid)
|
||||
if int(candidate.get("score") or 0) > int(existing.get("score") or 0):
|
||||
keep_count = existing.get("duplicate_count")
|
||||
keep_uids = existing.get("duplicate_uids")
|
||||
replacement = dict(candidate)
|
||||
replacement["duplicate_count"] = keep_count
|
||||
replacement["duplicate_uids"] = keep_uids
|
||||
deduped[key] = replacement
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
_FETCH_SEQ_RE = re.compile(rb"^(\d+)\s+\(")
|
||||
|
||||
|
||||
@@ -517,6 +759,82 @@ def _email_index_rows(owner: str, account_id: str | None, folder: str, uids: lis
|
||||
return out
|
||||
|
||||
|
||||
def _email_index_list(owner: str, account_id: str | None, folder: str, filter_: str, limit: int, offset: int, has_attachments: bool = False) -> tuple[list[dict], int, str | None]:
|
||||
"""Return a newest-first page from the durable local email index.
|
||||
|
||||
This is intentionally a paint-fast cache path for the UI, not the source of
|
||||
truth. The normal IMAP list still runs after this in the browser to refresh
|
||||
flags/new mail.
|
||||
"""
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
offset = max(0, int(offset or 0))
|
||||
account_key = _account_cache_key(account_id, owner)
|
||||
clauses = ["owner=?", "account_key=?", "folder=?"]
|
||||
params: list = [owner or "", account_key, folder]
|
||||
if filter_ == "unread":
|
||||
clauses.append("(flags IS NULL OR instr(flags, '\\Seen') = 0)")
|
||||
elif filter_ in {"unanswered", "undone"}:
|
||||
clauses.append("(flags IS NULL OR instr(flags, '\\Answered') = 0)")
|
||||
elif filter_ == "favorites":
|
||||
clauses.append("instr(COALESCE(flags, ''), '\\Flagged') > 0")
|
||||
elif filter_ not in {"all", "", None}:
|
||||
return [], 0, None
|
||||
if has_attachments:
|
||||
clauses.append("has_attachments=1")
|
||||
where = " AND ".join(clauses)
|
||||
try:
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
total_row = conn.execute(
|
||||
f"SELECT COUNT(*), MAX(updated_at) FROM email_message_index WHERE {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
total = int((total_row or [0])[0] or 0)
|
||||
if not total:
|
||||
return [], 0, (total_row or [None, None])[1]
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT uid, message_id, subject, from_name, from_address, to_text, cc_text,
|
||||
date_iso, date_display, date_epoch, size, flags, has_attachments
|
||||
FROM email_message_index
|
||||
WHERE {where}
|
||||
ORDER BY date_epoch DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[*params, limit, offset],
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.debug("email index list skipped", exc_info=True)
|
||||
return [], 0, None
|
||||
|
||||
emails: list[dict] = []
|
||||
for row in rows:
|
||||
uid, message_id, subject, from_name, from_address, to_text, cc_text, date_iso, date_display, date_epoch, size, flags, has_attachments_raw = row
|
||||
flags = flags or ""
|
||||
emails.append({
|
||||
"uid": str(uid),
|
||||
"message_id": (message_id or "").strip(),
|
||||
"subject": subject or "(no subject)",
|
||||
"from_name": from_name or from_address or "",
|
||||
"from_address": from_address or "",
|
||||
"to": to_text or "",
|
||||
"cc": cc_text or "",
|
||||
"date": date_iso or "",
|
||||
"date_display": date_display or "",
|
||||
"date_epoch": float(date_epoch or 0),
|
||||
"size": int(size or 0),
|
||||
"is_read": "\\Seen" in flags,
|
||||
"is_answered": "\\Answered" in flags,
|
||||
"is_flagged": "\\Flagged" in flags,
|
||||
"flags": flags,
|
||||
"has_attachments": bool(has_attachments_raw),
|
||||
"folder": folder,
|
||||
})
|
||||
return emails, total, (total_row or [None, None])[1]
|
||||
|
||||
|
||||
def _email_index_search(owner: str, account_id: str | None, folder: str, query: str, limit: int, global_search: bool = True) -> tuple[list[dict], int, str | None]:
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
@@ -990,8 +1308,7 @@ def _normalize_addr_field(field: str) -> str:
|
||||
"""Strip the malformed-but-common trailing/leading commas and stray
|
||||
whitespace from a To/Cc/Bcc string before it lands in the MIME header
|
||||
or the SMTP envelope. Users often paste a single address with a
|
||||
trailing comma (e.g. `user@example.com,`) and most MTAs reject the
|
||||
resulting `To: user@example.com,` line as a syntax error. Collapse
|
||||
trailing comma, which most MTAs reject as a syntax error. Collapse
|
||||
any run of separator junk between addresses too."""
|
||||
if not field:
|
||||
return field
|
||||
@@ -1162,7 +1479,7 @@ def setup_email_routes():
|
||||
_IMAP_POOL = {} # account_id → (conn, last_used_at)
|
||||
_IMAP_IDLE_MAX = 60.0
|
||||
_WARMING_READS = set()
|
||||
_WARM_READ_LIMIT = 2
|
||||
_WARM_READ_LIMIT = 6
|
||||
_WARM_MAX_BYTES = 192 * 1024
|
||||
_WARM_RECENT_SECONDS = 7 * 24 * 60 * 60
|
||||
_pool_lock = _threading.Lock()
|
||||
@@ -1245,6 +1562,10 @@ def setup_email_routes():
|
||||
return None
|
||||
return v[1]
|
||||
|
||||
def _folder_cache_get_stale(account_id, owner):
|
||||
v = _FOLDER_CACHE.get((account_id or "", owner or ""))
|
||||
return v[1] if v else None
|
||||
|
||||
def _folder_cache_put(account_id, owner, value):
|
||||
_FOLDER_CACHE[(account_id or "", owner or "")] = (_time.monotonic() + _FOLDER_TTL, value)
|
||||
if len(_FOLDER_CACHE) > 32:
|
||||
@@ -1951,18 +2272,44 @@ def setup_email_routes():
|
||||
from_addr: str | None = Query(None, alias="from"),
|
||||
account_id: str | None = Query(None),
|
||||
has_attachments: int = Query(0),
|
||||
cached_only: int = Query(0),
|
||||
cache_bust: str | None = Query(None, alias="_"),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""List emails. Uses an 8s in-memory cache + offloads blocking IMAP
|
||||
calls to a worker thread so the event loop never stalls."""
|
||||
started_at = _time.monotonic()
|
||||
_deferred = getattr(_start_poller, '_deferred', None)
|
||||
if _deferred:
|
||||
await _deferred()
|
||||
fixture_result = _fixture_email_list(folder, limit, offset, filter, from_addr, owner)
|
||||
if fixture_result is not None:
|
||||
return fixture_result
|
||||
if cached_only and not from_addr:
|
||||
indexed_emails, indexed_total, indexed_at = _email_index_list(
|
||||
owner, account_id, folder, filter, limit, offset, bool(has_attachments),
|
||||
)
|
||||
if indexed_total:
|
||||
_hide_unlinked_calendar_tags(indexed_emails)
|
||||
return {
|
||||
"emails": indexed_emails,
|
||||
"total": indexed_total,
|
||||
"folder": folder,
|
||||
"offset": offset,
|
||||
"sync": {
|
||||
"source": "index",
|
||||
"indexed": indexed_total,
|
||||
"updated_at": indexed_at,
|
||||
"cached_only": True,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"emails": [],
|
||||
"total": 0,
|
||||
"folder": folder,
|
||||
"offset": offset,
|
||||
"sync": {"source": "index", "cached_only": True},
|
||||
}
|
||||
_deferred = getattr(_start_poller, '_deferred', None)
|
||||
if _deferred:
|
||||
await _deferred()
|
||||
# SECURITY: include `owner` in the cache key so two users with
|
||||
# different account scopes don't share a cached list.
|
||||
ck = _list_cache_key(account_id, folder, filter, limit, offset, from_addr or "") + (int(bool(has_attachments)), owner)
|
||||
@@ -2090,6 +2437,230 @@ def setup_email_routes():
|
||||
logger.error(f"unflag-spam failed: {e}")
|
||||
return {"ok": False, "error": "Mail operation failed"}
|
||||
|
||||
def _unsubscribe_spam_cache(owner: str, account_id: str | None, folder: str) -> dict[str, dict]:
|
||||
out: dict[str, dict] = {}
|
||||
try:
|
||||
conn = _sql3.connect(SCHEDULED_DB)
|
||||
try:
|
||||
owner_clause, owner_params = _email_tag_owner_clause(account_id, owner)
|
||||
account_clause, account_params = _email_tag_account_clause(account_id)
|
||||
rows = conn.execute(
|
||||
"SELECT uid, message_id, spam_verdict, spam_reason FROM email_tags "
|
||||
"WHERE folder=? AND "
|
||||
f"{owner_clause} AND {account_clause}",
|
||||
(folder, *owner_params, *account_params),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for uid, message_id, spam_verdict, spam_reason in rows:
|
||||
payload = {"spam": bool(spam_verdict), "reason": spam_reason or ""}
|
||||
if uid:
|
||||
out[str(uid)] = payload
|
||||
if message_id:
|
||||
out[str(message_id).strip()] = payload
|
||||
except Exception:
|
||||
logger.debug("unsubscribe spam cache lookup skipped", exc_info=True)
|
||||
return out
|
||||
|
||||
def _scan_unsubscribe_candidates_sync(folder: str, account_id: str | None, owner: str, limit: int, max_scan: int) -> dict:
|
||||
folder = folder or "INBOX"
|
||||
limit = max(1, min(int(limit or 25), 100))
|
||||
max_scan = max(limit, min(int(max_scan or 150), 500))
|
||||
spam_cache = _unsubscribe_spam_cache(owner, account_id, folder)
|
||||
candidates: list[dict] = []
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
st, _ = conn.select(_q(folder), readonly=True)
|
||||
if st != "OK":
|
||||
return {"success": False, "error": f"Folder not found: {folder}", "candidates": []}
|
||||
st, data = _imap_uid_search(conn, "ALL")
|
||||
if st != "OK" or not data or not data[0]:
|
||||
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
|
||||
uids = []
|
||||
for raw_uid in data[0].split():
|
||||
try:
|
||||
uids.append(int(raw_uid))
|
||||
except Exception:
|
||||
continue
|
||||
uids = sorted(uids, reverse=True)[:max_scan]
|
||||
if not uids:
|
||||
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder}
|
||||
fetch_set = ",".join(str(u) for u in uids)
|
||||
st, msg_data = _imap_uid_fetch(conn, fetch_set, "(UID RFC822.HEADER)")
|
||||
if st != "OK":
|
||||
return {"success": False, "error": "Failed to fetch email headers", "candidates": []}
|
||||
for item in msg_data or []:
|
||||
if not isinstance(item, tuple) or len(item) < 2:
|
||||
continue
|
||||
meta_b = item[0] if isinstance(item[0], bytes) else str(item[0]).encode()
|
||||
uid = _uid_from_fetch_meta(meta_b)
|
||||
if not uid:
|
||||
continue
|
||||
try:
|
||||
msg = email_mod.message_from_bytes(item[1] or b"")
|
||||
except Exception:
|
||||
continue
|
||||
mid = (msg.get("Message-ID") or "").strip()
|
||||
spam_info = spam_cache.get(uid) or (spam_cache.get(mid) if mid else None) or {}
|
||||
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder, spam_cached=spam_info)
|
||||
if candidate:
|
||||
candidates.append(candidate)
|
||||
def _uid_sort_value(candidate: dict) -> int:
|
||||
try:
|
||||
return int(candidate.get("uid") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
raw_total = len(candidates)
|
||||
candidates = _dedupe_unsubscribe_candidates(candidates)
|
||||
candidates.sort(key=lambda c: (int(c.get("score") or 0), int(c.get("duplicate_count") or 1), _uid_sort_value(c)), reverse=True)
|
||||
return {
|
||||
"success": True,
|
||||
"candidates": candidates[:limit],
|
||||
"total": len(candidates),
|
||||
"raw_total": raw_total,
|
||||
"scanned": len(uids),
|
||||
"folder": folder,
|
||||
"account_id": account_id or "",
|
||||
}
|
||||
|
||||
@router.get("/unsubscribe/scan")
|
||||
async def scan_unsubscribe_candidates(
|
||||
folder: str = Query("INBOX"),
|
||||
account_id: str | None = Query(None),
|
||||
limit: int = Query(25),
|
||||
max_scan: int = Query(150),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""Review-only scan for spam/newsletter unsubscribe candidates."""
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
if _fixture_email_enabled():
|
||||
return {"success": True, "candidates": [], "total": 0, "scanned": 0, "folder": folder, "sync": {"source": "fixture"}}
|
||||
try:
|
||||
return await _asyncio.to_thread(_scan_unsubscribe_candidates_sync, folder, account_id, owner, limit, max_scan)
|
||||
except Exception as e:
|
||||
logger.error(f"unsubscribe scan failed: {e}")
|
||||
return {"success": False, "error": "Mail operation failed", "candidates": []}
|
||||
|
||||
@router.post("/unsubscribe/execute")
|
||||
def execute_unsubscribe(data: dict, owner: str = Depends(require_owner)):
|
||||
"""Execute an approved unsubscribe action.
|
||||
|
||||
First implementation supports mailto List-Unsubscribe only. The message
|
||||
header is re-read server-side; client-supplied addresses are not trusted.
|
||||
"""
|
||||
uid = str((data or {}).get("uid") or "").strip()
|
||||
folder = str((data or {}).get("folder") or "INBOX").strip() or "INBOX"
|
||||
account_id = (data or {}).get("account_id") or None
|
||||
method_index = int((data or {}).get("method_index") or 0)
|
||||
move_to_spam = bool((data or {}).get("move_to_spam"))
|
||||
if not uid:
|
||||
raise HTTPException(400, "Missing uid")
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
st, _ = conn.select(_q(folder), readonly=True)
|
||||
if st != "OK":
|
||||
return {"success": False, "error": f"Folder not found: {folder}"}
|
||||
st, msg_data = _imap_uid_fetch(conn, uid, "(UID RFC822.HEADER)")
|
||||
if st != "OK" or not msg_data:
|
||||
return {"success": False, "error": "Email not found"}
|
||||
raw_header = b""
|
||||
for item in msg_data or []:
|
||||
if isinstance(item, tuple) and len(item) >= 2:
|
||||
raw_header = item[1] or b""
|
||||
break
|
||||
msg = email_mod.message_from_bytes(raw_header)
|
||||
candidate = _email_unsubscribe_candidate_from_msg(msg, uid, folder)
|
||||
if not candidate:
|
||||
return {"success": False, "error": "No unsubscribe header found for this email"}
|
||||
methods = [m for m in (candidate.get("methods") or []) if m.get("kind") == "mailto" and m.get("executable")]
|
||||
if not methods:
|
||||
return {"success": False, "error": "This email only has web unsubscribe links; open manually for now", "candidate": candidate}
|
||||
method = methods[method_index] if 0 <= method_index < len(methods) else methods[0]
|
||||
target = str(method.get("target") or "").strip()
|
||||
if not target or "\r" in target or "\n" in target:
|
||||
return {"success": False, "error": "Invalid unsubscribe address"}
|
||||
cfg = _resolve_send_config(account_id, owner=owner)
|
||||
subject = str(method.get("subject") or "unsubscribe")[:200]
|
||||
body = str(method.get("body") or "unsubscribe")[:1000]
|
||||
msg_out = MIMEText(body or "unsubscribe", "plain", "utf-8")
|
||||
msg_out["From"] = email.utils.formataddr((cfg.get("display_name") or "", cfg["from_address"]))
|
||||
msg_out["To"] = target
|
||||
msg_out["Subject"] = subject
|
||||
msg_out["Message-ID"] = email.utils.make_msgid(domain="odysseus.local")
|
||||
_apply_odysseus_headers(msg_out, "unsubscribe", uid)
|
||||
_send_smtp_message(cfg, cfg["from_address"], [target], msg_out.as_string())
|
||||
moved = False
|
||||
if move_to_spam:
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder))
|
||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||
if moved:
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
_invalidate_list_cache(account_id)
|
||||
except Exception:
|
||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||
return {
|
||||
"success": True,
|
||||
"method": method,
|
||||
"candidate": candidate,
|
||||
"moved_to_spam": moved,
|
||||
}
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"unsubscribe execute failed uid={uid}: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.post("/unsubscribe/cleanup")
|
||||
def cleanup_unsubscribe_candidates(data: dict, owner: str = Depends(require_owner)):
|
||||
"""Move reviewed unsubscribe candidate messages to Junk or Trash."""
|
||||
folder = str((data or {}).get("folder") or "INBOX").strip() or "INBOX"
|
||||
account_id = (data or {}).get("account_id") or None
|
||||
action = str((data or {}).get("action") or "").strip().lower()
|
||||
raw_uids = (data or {}).get("uids") or []
|
||||
if action not in {"junk", "delete"}:
|
||||
raise HTTPException(400, "Unsupported cleanup action")
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
uids: list[str] = []
|
||||
seen = set()
|
||||
for raw_uid in raw_uids:
|
||||
uid = str(raw_uid or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
if not uid.isdigit():
|
||||
continue
|
||||
seen.add(uid)
|
||||
uids.append(uid)
|
||||
if not uids:
|
||||
return {"success": False, "error": "No email UIDs provided", "changed": 0, "failed": 0}
|
||||
role = "junk" if action == "junk" else "trash"
|
||||
target = "Junk" if action == "junk" else "Trash"
|
||||
changed = 0
|
||||
failed = 0
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder))
|
||||
for uid in uids:
|
||||
try:
|
||||
if _move_email_message(conn, uid, target, role=role):
|
||||
changed += 1
|
||||
_email_index_delete(owner, account_id, folder, uid)
|
||||
else:
|
||||
failed += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
logger.debug("unsubscribe cleanup failed for uid=%s", uid, exc_info=True)
|
||||
if changed:
|
||||
_invalidate_list_cache(account_id)
|
||||
return {"success": True, "action": action, "changed": changed, "failed": failed}
|
||||
except Exception as e:
|
||||
logger.error(f"unsubscribe cleanup failed: {e}")
|
||||
return {"success": False, "error": "Mail operation failed", "changed": changed, "failed": failed}
|
||||
|
||||
@router.get("/contacts")
|
||||
async def list_contacts(
|
||||
q: str = Query(""),
|
||||
@@ -2566,7 +3137,7 @@ def setup_email_routes():
|
||||
return
|
||||
|
||||
async def _warm():
|
||||
await _asyncio.sleep(3.0)
|
||||
await _asyncio.sleep(0.25)
|
||||
for uid, ck in selected:
|
||||
if _read_cache_get(ck) is not None:
|
||||
_WARMING_READS.discard(ck)
|
||||
@@ -2739,7 +3310,7 @@ def setup_email_routes():
|
||||
return {"error": "Mail operation failed"}
|
||||
|
||||
@router.post("/attachment-as-doc/{uid}/{index}")
|
||||
async def attachment_as_doc(uid: str, index: int, request: Request, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||
def attachment_as_doc(uid: str, index: int, request: Request, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||
"""Extract an email attachment and open it in the document editor.
|
||||
|
||||
Supported extensions:
|
||||
@@ -3225,7 +3796,11 @@ def setup_email_routes():
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.get("/folders")
|
||||
async def list_folders(account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||
async def list_folders(
|
||||
account_id: str | None = Query(None),
|
||||
cached_only: int = Query(0),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""List IMAP folders."""
|
||||
if _fixture_email_enabled():
|
||||
return {"folders": ["INBOX", "Archive", "Sent"], "sync": {"source": "fixture"}}
|
||||
@@ -3236,19 +3811,57 @@ def setup_email_routes():
|
||||
sync_meta["source"] = "folder_cache"
|
||||
payload["sync"] = sync_meta
|
||||
return payload
|
||||
try:
|
||||
if cached_only:
|
||||
stale = _folder_cache_get_stale(account_id, owner)
|
||||
if stale:
|
||||
payload = dict(stale)
|
||||
sync_meta = dict(payload.get("sync") or {})
|
||||
sync_meta["source"] = "folder_cache_stale"
|
||||
payload["sync"] = sync_meta
|
||||
return payload
|
||||
return {
|
||||
"folders": ["INBOX", "Sent", "Archive"],
|
||||
"sync": {"source": "folder_cached_only_fallback"},
|
||||
}
|
||||
|
||||
def _list_folders_sync():
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
status, folders = conn.list()
|
||||
result = []
|
||||
for f in folders:
|
||||
for f in folders or []:
|
||||
decoded = f.decode() if isinstance(f, bytes) else f
|
||||
match = re.search(r'"([^"]*)"$|(\S+)$', decoded)
|
||||
if match:
|
||||
name = match.group(1) or match.group(2)
|
||||
result.append(name)
|
||||
payload = {"folders": result, "sync": {"source": "imap", "updated_at": datetime.utcnow().isoformat() + "Z"}}
|
||||
return {
|
||||
"folders": result,
|
||||
"sync": {
|
||||
"source": "imap",
|
||||
"updated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"status": status.decode() if isinstance(status, bytes) else status,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
payload = await _asyncio.wait_for(_asyncio.to_thread(_list_folders_sync), timeout=8.0)
|
||||
_folder_cache_put(account_id, owner, payload)
|
||||
return payload
|
||||
except _asyncio.TimeoutError:
|
||||
logger.warning(f"list_folders timed out for account={account_id or 'default'} owner={owner}")
|
||||
stale = _folder_cache_get_stale(account_id, owner)
|
||||
if stale:
|
||||
payload = dict(stale)
|
||||
sync_meta = dict(payload.get("sync") or {})
|
||||
sync_meta["source"] = "folder_cache_stale"
|
||||
sync_meta["warning"] = "Folder list timed out"
|
||||
payload["sync"] = sync_meta
|
||||
return payload
|
||||
return {
|
||||
"folders": ["INBOX", "Sent", "Archive"],
|
||||
"error": "Folder list timed out",
|
||||
"sync": {"source": "folder_timeout_fallback"},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"list_folders failed: {e}")
|
||||
return {"folders": [], "error": "Mail operation failed"}
|
||||
@@ -4040,17 +4653,23 @@ def setup_email_routes():
|
||||
return {"success": True, "message": "Draft saved"}
|
||||
|
||||
@router.post("/extract-style")
|
||||
async def extract_writing_style(req: ExtractStyleRequest, owner: str = Depends(require_owner)):
|
||||
async def extract_writing_style(
|
||||
req: ExtractStyleRequest,
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""Extract writing style from sent emails using LLM.
|
||||
|
||||
IMAP fetch is offloaded to a worker thread; the LLM call uses the
|
||||
async client. Otherwise this handler froze the event loop for ~5s
|
||||
on the IMAP step alone with a remote server.
|
||||
"""
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
|
||||
def _gather_samples() -> tuple[list[str], str | None]:
|
||||
try:
|
||||
with _imap(owner=owner) as imap:
|
||||
with _imap(account_id, owner=owner) as imap:
|
||||
imap.select(_q(_detect_sent_folder(imap)), readonly=True)
|
||||
status, data = imap.search(None, "ALL")
|
||||
if status != "OK" or not data[0]:
|
||||
@@ -4132,7 +4751,7 @@ def setup_email_routes():
|
||||
|
||||
# Save to settings
|
||||
settings = _load_settings()
|
||||
settings["email_writing_style"] = style
|
||||
_set_email_writing_style_for_account(settings, style, account_id)
|
||||
_save_settings(settings)
|
||||
|
||||
logger.info("Writing style extracted and saved")
|
||||
@@ -4424,8 +5043,11 @@ def setup_email_routes():
|
||||
message_id = (data.get("message_id") or "").strip()
|
||||
source_uid = (data.get("uid") or "").strip()
|
||||
source_folder = (data.get("folder") or "INBOX").strip()
|
||||
account_id = (data.get("account_id") or "").strip() or None
|
||||
fast_reply = bool(data.get("fast", False))
|
||||
user_hint = (data.get("user_hint") or "").strip()
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
|
||||
if not original_body:
|
||||
return {"success": False, "error": "No email body provided"}
|
||||
@@ -4433,7 +5055,7 @@ def setup_email_routes():
|
||||
# Skip cache lookup when the caller supplied a user_hint — the
|
||||
# cached generic reply doesn't reflect the instructions and
|
||||
# would silently override them.
|
||||
if message_id and not user_hint:
|
||||
if message_id and not user_hint and not account_id:
|
||||
try:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||
@@ -4455,7 +5077,7 @@ def setup_email_routes():
|
||||
logger.warning(f"AI reply cache lookup failed: {e}")
|
||||
|
||||
settings = _load_settings()
|
||||
style = settings.get("email_writing_style", "")
|
||||
style = _get_email_writing_style_for_account(settings, account_id)
|
||||
|
||||
# Try session's endpoint first if session_id provided
|
||||
url = None
|
||||
@@ -4576,8 +5198,11 @@ def setup_email_routes():
|
||||
)
|
||||
if user_hint:
|
||||
user_msg += (
|
||||
f"User's instructions for THIS reply (follow these — they override "
|
||||
f"defaults like length/tone):\n{user_hint[:2000]}\n\n"
|
||||
"User guidance for THIS reply. Treat this as intent/context to fold "
|
||||
"into a normal polished email reply in the user's writing style. "
|
||||
"Do not answer with only this guidance unless the user explicitly "
|
||||
"asked for a one-word reply:\n"
|
||||
f"{user_hint[:2000]}\n\n"
|
||||
)
|
||||
user_msg += "Draft a reply. Return only the reply body text."
|
||||
|
||||
@@ -4703,23 +5328,41 @@ def setup_email_routes():
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.get("/style")
|
||||
async def get_writing_style(owner: str = Depends(require_user)):
|
||||
async def get_writing_style(
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_user),
|
||||
):
|
||||
"""Get the current writing style prompt."""
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
settings = _load_settings()
|
||||
return {"style": settings.get("email_writing_style", "")}
|
||||
return {"style": _get_email_writing_style_for_account(settings, account_id)}
|
||||
|
||||
@router.put("/style")
|
||||
async def update_writing_style(data: dict, owner: str = Depends(require_user)):
|
||||
async def update_writing_style(
|
||||
data: dict,
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_user),
|
||||
):
|
||||
"""Manually update the writing style prompt."""
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
settings = _load_settings()
|
||||
settings["email_writing_style"] = data.get("style", "")
|
||||
_set_email_writing_style_for_account(settings, data.get("style", ""), account_id)
|
||||
_save_settings(settings)
|
||||
return {"success": True}
|
||||
|
||||
@router.get("/config")
|
||||
async def get_email_config(owner: str = Depends(require_user)):
|
||||
async def get_email_config(
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_user),
|
||||
):
|
||||
"""Get email configuration (passwords masked)."""
|
||||
cfg = _get_email_config(owner=owner)
|
||||
if account_id is not None and not isinstance(account_id, str):
|
||||
account_id = None
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
cfg = _get_email_config(account_id, owner=owner)
|
||||
cfg["smtp_password"] = "***" if cfg["smtp_password"] else ""
|
||||
cfg["imap_password"] = "***" if cfg["imap_password"] else ""
|
||||
# `_get_email_config` includes encrypted OAuth fields for the server's
|
||||
@@ -4731,11 +5374,21 @@ def setup_email_routes():
|
||||
cfg.pop("oauth_token_expiry", None)
|
||||
# Include preferences from settings.json
|
||||
settings = _load_settings()
|
||||
auto_reply_settings = _get_auto_reply_settings_for_account(settings, account_id)
|
||||
cfg["email_auto_summarize"] = bool(settings.get("email_auto_summarize", False))
|
||||
cfg["email_auto_reply"] = bool(settings.get("email_auto_reply", False))
|
||||
cfg["email_auto_reply"] = bool(auto_reply_settings.get("email_auto_reply", False))
|
||||
cfg["email_auto_tag"] = bool(settings.get("email_auto_tag", False))
|
||||
cfg["email_auto_spam"] = bool(settings.get("email_auto_spam", False))
|
||||
cfg["email_auto_calendar"] = bool(settings.get("email_auto_calendar", False))
|
||||
cfg["email_auto_reply_start"] = auto_reply_settings.get("email_auto_reply_start", "")
|
||||
cfg["email_auto_reply_end"] = auto_reply_settings.get("email_auto_reply_end", "")
|
||||
cfg["email_auto_reply_subject"] = auto_reply_settings.get("email_auto_reply_subject", "(Away) {subject}")
|
||||
cfg["email_auto_reply_message"] = auto_reply_settings.get("email_auto_reply_message", "")
|
||||
cfg["email_auto_reply_cooldown"] = auto_reply_settings.get("email_auto_reply_cooldown", "period")
|
||||
cfg["email_auto_reply_scope"] = auto_reply_settings.get("email_auto_reply_scope", "account" if account_id else "all")
|
||||
cfg["email_auto_reply_account_id"] = auto_reply_settings.get("email_auto_reply_account_id", account_id or "")
|
||||
cfg["email_auto_reply_exclude_automated"] = bool(auto_reply_settings.get("email_auto_reply_exclude_automated", True))
|
||||
cfg["email_auto_reply_pause_notifications"] = bool(auto_reply_settings.get("email_auto_reply_pause_notifications", False))
|
||||
# Email translation is owned by the background task now; opening an email
|
||||
# should not trigger reader-side auto-translation from Settings.
|
||||
cfg["email_auto_translate"] = False
|
||||
@@ -4743,7 +5396,11 @@ def setup_email_routes():
|
||||
return cfg
|
||||
|
||||
@router.put("/config")
|
||||
async def update_email_config(data: dict, owner: str = Depends(require_owner)):
|
||||
async def update_email_config(
|
||||
data: dict,
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""Update email configuration.
|
||||
|
||||
Automation flags (email_auto_*) still live in settings.json. Credentials
|
||||
@@ -4751,11 +5408,19 @@ def setup_email_routes():
|
||||
overwritten when a non-empty value is provided, so saving the form
|
||||
without retyping the password no longer wipes it.
|
||||
"""
|
||||
# Automation flags stay in settings.json (they're global, not per-account)
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
|
||||
# Non-reply automation flags stay global. Away/auto-reply settings are
|
||||
# account-scoped when an account_id is supplied by the Email Settings UI.
|
||||
settings = _load_settings()
|
||||
for key in ["email_auto_summarize", "email_auto_reply", "email_auto_tag", "email_auto_spam", "email_auto_calendar"]:
|
||||
bool_keys = [
|
||||
"email_auto_summarize", "email_auto_tag", "email_auto_spam", "email_auto_calendar",
|
||||
]
|
||||
for key in bool_keys:
|
||||
if key in data:
|
||||
settings[key] = data[key]
|
||||
settings[key] = bool(data[key])
|
||||
_set_auto_reply_settings_for_account(settings, data, account_id)
|
||||
_save_settings(settings)
|
||||
|
||||
# Credentials go into the default account row
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Gallery routes — browsable library for photos and AI-generated images."""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@@ -27,6 +29,165 @@ from routes.gallery.gallery_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SAM_STATE: Dict[str, Any] = {}
|
||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"):
|
||||
if not image_b64:
|
||||
raise HTTPException(400, "Missing image")
|
||||
if "," in image_b64 and image_b64.split(",", 1)[0].startswith("data:"):
|
||||
image_b64 = image_b64.split(",", 1)[1]
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
raw = base64.b64decode(image_b64)
|
||||
return Image.open(io.BytesIO(raw)).convert(mode)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, "Invalid image") from exc
|
||||
|
||||
|
||||
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=fmt)
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def _load_sam_backend():
|
||||
model_id = os.getenv("ODYSSEUS_SAM_MODEL", "facebook/sam-vit-base")
|
||||
cached = _SAM_STATE.get(model_id)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
import torch
|
||||
from transformers import SamModel, SamProcessor
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
501,
|
||||
"SAM mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
|
||||
) from exc
|
||||
|
||||
device = "cpu"
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
device = "cuda"
|
||||
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
||||
device = "mps"
|
||||
except Exception:
|
||||
device = "cpu"
|
||||
|
||||
try:
|
||||
processor = SamProcessor.from_pretrained(model_id)
|
||||
model = SamModel.from_pretrained(model_id)
|
||||
model.to(device)
|
||||
model.eval()
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"Failed to load SAM model {model_id}: {exc}") from exc
|
||||
|
||||
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
|
||||
_SAM_STATE[model_id] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _load_grounding_backend():
|
||||
model_id = os.getenv("ODYSSEUS_GROUNDING_MODEL", "google/owlvit-base-patch32")
|
||||
cached = _GROUNDING_STATE.get(model_id)
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
import torch
|
||||
from transformers import OwlViTForObjectDetection, OwlViTProcessor
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
501,
|
||||
"Object mask tools are not installed. Install Cookbook Dependencies -> SAM mask tools.",
|
||||
) from exc
|
||||
|
||||
device = "cpu"
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
device = "cuda"
|
||||
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
||||
device = "mps"
|
||||
except Exception:
|
||||
device = "cpu"
|
||||
|
||||
try:
|
||||
processor = OwlViTProcessor.from_pretrained(model_id)
|
||||
model = OwlViTForObjectDetection.from_pretrained(model_id)
|
||||
model.to(device)
|
||||
model.eval()
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"Failed to load object mask model {model_id}: {exc}") from exc
|
||||
|
||||
cached = {"torch": torch, "processor": processor, "model": model, "device": device, "model_id": model_id}
|
||||
_GROUNDING_STATE[model_id] = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
||||
query = (text or "").strip()
|
||||
if not query:
|
||||
raise HTTPException(400, "Missing object text")
|
||||
backend = _load_grounding_backend()
|
||||
torch = backend["torch"]
|
||||
processor = backend["processor"]
|
||||
model = backend["model"]
|
||||
device = backend["device"]
|
||||
|
||||
labels = [query]
|
||||
if not query.lower().startswith(("a ", "an ", "the ")):
|
||||
labels.append(f"a photo of {query}")
|
||||
try:
|
||||
inputs = processor(text=[labels], images=image, return_tensors="pt")
|
||||
model_inputs = {
|
||||
k: (v.to(device) if hasattr(v, "to") else v)
|
||||
for k, v in inputs.items()
|
||||
}
|
||||
with torch.no_grad():
|
||||
outputs = model(**model_inputs)
|
||||
target_sizes = torch.tensor([[image.height, image.width]])
|
||||
if hasattr(processor, "post_process_object_detection"):
|
||||
results = processor.post_process_object_detection(
|
||||
outputs=outputs,
|
||||
target_sizes=target_sizes,
|
||||
threshold=float(threshold),
|
||||
)
|
||||
elif hasattr(processor, "post_process_grounded_object_detection"):
|
||||
results = processor.post_process_grounded_object_detection(
|
||||
outputs=outputs,
|
||||
target_sizes=target_sizes,
|
||||
threshold=float(threshold),
|
||||
text_labels=[labels],
|
||||
)
|
||||
else:
|
||||
raise HTTPException(500, "Installed Transformers does not expose OWL-ViT object detection post-processing")
|
||||
boxes = results[0].get("boxes")
|
||||
scores = results[0].get("scores")
|
||||
labels_idx = results[0].get("labels")
|
||||
text_labels = results[0].get("text_labels") or results[0].get("labels_text")
|
||||
if boxes is None or scores is None or len(boxes) == 0:
|
||||
raise HTTPException(404, f"No visible object matched '{query}'")
|
||||
idx = int(torch.argmax(scores).item())
|
||||
box = [float(v) for v in boxes[idx].detach().cpu().tolist()]
|
||||
label_idx = int(labels_idx[idx].detach().cpu().item()) if labels_idx is not None and len(labels_idx) else 0
|
||||
label = labels[min(label_idx, len(labels) - 1)]
|
||||
if text_labels and len(text_labels) > idx:
|
||||
label = str(text_labels[idx])
|
||||
return {
|
||||
"box": box,
|
||||
"score": float(scores[idx].detach().cpu().item()),
|
||||
"label": label,
|
||||
"model": backend["model_id"],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("ground_text_to_box failed")
|
||||
raise HTTPException(500, f"Object mask failed: {exc}") from exc
|
||||
|
||||
|
||||
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||
if not user:
|
||||
@@ -1240,20 +1401,89 @@ def setup_gallery_routes() -> APIRouter:
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(504, "OpenAI inpaint timed out (120s)")
|
||||
|
||||
# Self-hosted diffusion server path
|
||||
# Self-hosted diffusion server path. Newer Odysseus image
|
||||
# wrappers expose the OpenAI-compatible /v1/images/edits
|
||||
# multipart route even when they are local/self-hosted. Older
|
||||
# diffusion_server.py exposes /v1/images/inpaint as JSON. Try the
|
||||
# OpenAI-compatible local route first, then fall back.
|
||||
try:
|
||||
# Forward chosen_model so the diffusion server can route if it ever
|
||||
# supports multiple models per process. Harmless if ignored.
|
||||
if chosen_model:
|
||||
body["model"] = chosen_model
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with httpx.AsyncClient(timeout=240) as client:
|
||||
try:
|
||||
import base64, io
|
||||
from PIL import Image
|
||||
|
||||
img_bytes = base64.b64decode(body["image"])
|
||||
mask_bytes = base64.b64decode(body["mask"])
|
||||
# Normalize both inputs to PNG bytes. Local MLX and
|
||||
# Diffusers wrappers expect white mask pixels to mean
|
||||
# "edit this region", which matches the editor's mask.
|
||||
source_png = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
||||
mask_png = Image.open(io.BytesIO(mask_bytes)).convert("L")
|
||||
src_buf = io.BytesIO()
|
||||
source_png.save(src_buf, format="PNG")
|
||||
mask_buf = io.BytesIO()
|
||||
mask_png.save(mask_buf, format="PNG")
|
||||
files = {
|
||||
"image": ("source.png", src_buf.getvalue(), "image/png"),
|
||||
"mask": ("mask.png", mask_buf.getvalue(), "image/png"),
|
||||
}
|
||||
data = {
|
||||
"model": chosen_model or body.get("model") or "",
|
||||
"prompt": body.get("prompt", ""),
|
||||
"size": f"{int(body.get('width') or source_png.width)}x{int(body.get('height') or source_png.height)}",
|
||||
"n": "1",
|
||||
}
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), data=data, files=files)
|
||||
if r.status_code == 200:
|
||||
result = r.json()
|
||||
if isinstance(result, dict) and result.get("data"):
|
||||
item = result["data"][0]
|
||||
if item.get("b64_json"):
|
||||
return {"image": item["b64_json"]}
|
||||
if item.get("url"):
|
||||
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||
if raw_b64:
|
||||
return {"image": raw_b64}
|
||||
if isinstance(result, dict) and result.get("image"):
|
||||
return {"image": result["image"]}
|
||||
raise HTTPException(502, "Image edit endpoint returned no image")
|
||||
if r.status_code not in (404, 405):
|
||||
logger.warning("inpaint_proxy self-hosted edits: status %s", r.status_code)
|
||||
detail = "Image edit request failed"
|
||||
try:
|
||||
err = r.json()
|
||||
detail = err.get("detail") or err.get("error") or detail
|
||||
except Exception:
|
||||
pass
|
||||
# A plain SD/SDXL checkpoint often exposes
|
||||
# generation only at /images/edits.
|
||||
# That does not mean the endpoint cannot inpaint:
|
||||
# Odysseus diffusion_server.py has a dedicated
|
||||
# /images/inpaint route that can derive/fallback to
|
||||
# inpaint, img2img crop+composite, or txt2img
|
||||
# crop+composite. Fall through to that route instead
|
||||
# of surfacing "does not support image edits".
|
||||
if r.status_code == 400 and "does not support image edits" in str(detail).lower():
|
||||
logger.info("inpaint_proxy self-hosted edits unsupported; falling back to /images/inpaint")
|
||||
else:
|
||||
raise HTTPException(r.status_code, detail)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("inpaint_proxy: failed to prepare self-hosted edit request")
|
||||
raise HTTPException(400, "Failed to prepare inpaint request")
|
||||
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/inpaint"), json=body)
|
||||
if r.status_code != 200:
|
||||
logger.error("inpaint_proxy diffusion: status %s", r.status_code)
|
||||
raise HTTPException(r.status_code, "Inpaint request failed")
|
||||
return r.json()
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(504, "Inpaint request timed out (120s)")
|
||||
raise HTTPException(504, "Inpaint request timed out (240s)")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -1588,6 +1818,135 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"error": "AI upscale failed"}
|
||||
|
||||
# ---- POST /api/image/remove-bg ----
|
||||
@router.post("/api/image/mask")
|
||||
async def smart_mask(request: Request):
|
||||
"""Create a neutral segmentation mask from user-provided points or a box.
|
||||
|
||||
This endpoint intentionally does not inspect edit prompts. It only
|
||||
turns explicit visual selection hints into a binary mask that the
|
||||
editor can reuse for wand/layer-mask/inpaint workflows.
|
||||
"""
|
||||
require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
image = _b64_to_pil_image(body.get("image") or "", mode="RGB")
|
||||
points = body.get("points") or []
|
||||
box = body.get("box")
|
||||
text = (body.get("text") or body.get("query") or "").strip()
|
||||
grounded = None
|
||||
|
||||
if not points and not box and text:
|
||||
grounded = _ground_text_to_box(image, text)
|
||||
box = grounded["box"]
|
||||
|
||||
if not points and not box:
|
||||
raise HTTPException(400, "Provide at least one point, box, or object text")
|
||||
|
||||
backend = _load_sam_backend()
|
||||
torch = backend["torch"]
|
||||
processor = backend["processor"]
|
||||
model = backend["model"]
|
||||
device = backend["device"]
|
||||
|
||||
kwargs: Dict[str, Any] = {"return_tensors": "pt"}
|
||||
input_points = []
|
||||
if points:
|
||||
input_labels = []
|
||||
for p in points:
|
||||
try:
|
||||
input_points.append([float(p["x"]), float(p["y"])])
|
||||
input_labels.append(int(p.get("label", 1)))
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, "Invalid point format") from exc
|
||||
kwargs["input_points"] = [input_points]
|
||||
kwargs["input_labels"] = [input_labels]
|
||||
if box:
|
||||
if not isinstance(box, list) or len(box) != 4:
|
||||
raise HTTPException(400, "Box must be [x1, y1, x2, y2]")
|
||||
try:
|
||||
kwargs["input_boxes"] = [[[float(v) for v in box]]]
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, "Invalid box format") from exc
|
||||
|
||||
try:
|
||||
inputs = processor(image, **kwargs)
|
||||
model_inputs = {
|
||||
k: (v.to(device) if hasattr(v, "to") else v)
|
||||
for k, v in inputs.items()
|
||||
}
|
||||
with torch.no_grad():
|
||||
outputs = model(**model_inputs)
|
||||
masks = processor.image_processor.post_process_masks(
|
||||
outputs.pred_masks.detach().cpu(),
|
||||
inputs["original_sizes"].detach().cpu(),
|
||||
inputs["reshaped_input_sizes"].detach().cpu(),
|
||||
)
|
||||
mask_tensor = masks[0]
|
||||
while getattr(mask_tensor, "ndim", 0) > 3:
|
||||
mask_tensor = mask_tensor[0]
|
||||
if getattr(mask_tensor, "ndim", 0) == 3:
|
||||
scores = outputs.iou_scores.detach().cpu()[0]
|
||||
while getattr(scores, "ndim", 0) > 1:
|
||||
scores = scores[0]
|
||||
# SAM commonly returns multiple candidates for a click. The
|
||||
# highest-IoU candidate can be the entire image, which is
|
||||
# useless as an editor selection. Prefer a candidate that
|
||||
# contains the clicked point while keeping area reasonable.
|
||||
point_xy = None
|
||||
if input_points:
|
||||
try:
|
||||
point_xy = (
|
||||
int(round(float(input_points[0][0]))),
|
||||
int(round(float(input_points[0][1]))),
|
||||
)
|
||||
except Exception:
|
||||
point_xy = None
|
||||
best_idx = 0
|
||||
best_rank = None
|
||||
total_px = max(1, int(mask_tensor.shape[-1]) * int(mask_tensor.shape[-2]))
|
||||
for i in range(int(mask_tensor.shape[0])):
|
||||
candidate = mask_tensor[i]
|
||||
area_ratio = float(candidate.sum().item()) / float(total_px)
|
||||
if area_ratio >= 0.985:
|
||||
continue
|
||||
contains_click = True
|
||||
if point_xy:
|
||||
px = max(0, min(int(candidate.shape[-1]) - 1, point_xy[0]))
|
||||
py = max(0, min(int(candidate.shape[-2]) - 1, point_xy[1]))
|
||||
contains_click = bool(candidate[py, px].item())
|
||||
if not contains_click:
|
||||
continue
|
||||
score = float(scores[min(i, len(scores) - 1)].item()) if len(scores) else 0.0
|
||||
# Strongly penalize broad masks; a click-selection should
|
||||
# usually be local unless the user gives a box.
|
||||
rank = score - (area_ratio * 0.35)
|
||||
if best_rank is None or rank > best_rank:
|
||||
best_rank = rank
|
||||
best_idx = i
|
||||
if best_rank is None and len(scores):
|
||||
best_idx = int(torch.argmax(scores).item())
|
||||
mask_tensor = mask_tensor[min(best_idx, mask_tensor.shape[0] - 1)]
|
||||
mask_array = (mask_tensor.numpy() > 0).astype("uint8") * 255
|
||||
from PIL import Image
|
||||
|
||||
mask_img = Image.fromarray(mask_array, mode="L")
|
||||
if mask_img.size != image.size:
|
||||
mask_img = mask_img.resize(image.size, Image.NEAREST)
|
||||
bbox = mask_img.getbbox()
|
||||
result = {
|
||||
"mask": _pil_image_to_b64(mask_img),
|
||||
"bbox": list(bbox) if bbox else None,
|
||||
"model": backend["model_id"],
|
||||
"device": device,
|
||||
}
|
||||
if grounded:
|
||||
result["grounding"] = grounded
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("smart_mask failed")
|
||||
raise HTTPException(500, f"SAM mask failed: {exc}") from exc
|
||||
|
||||
@router.post("/api/image/remove-bg")
|
||||
async def remove_background(request: Request):
|
||||
"""Remove background from an image. If the client passes a `hint_mask`
|
||||
|
||||
@@ -137,6 +137,44 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
entry["metadata"] = meta
|
||||
return entry
|
||||
|
||||
def _db_message_metadata(m: DbChatMessage) -> Dict[str, Any]:
|
||||
meta = {}
|
||||
if m.meta_data:
|
||||
try:
|
||||
meta = json.loads(m.meta_data) or {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
meta = {}
|
||||
if m.timestamp and "timestamp" not in meta:
|
||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||
return meta
|
||||
|
||||
def _hydrate_session_history_from_db(session_id: str, rows: list[DbChatMessage]) -> None:
|
||||
"""Rebuild in-memory context from raw DB rows after a history load.
|
||||
|
||||
The browser history endpoint can return paged/display-trimmed messages,
|
||||
but the next model call reads ``session.history``. After a restart or a
|
||||
stale in-memory session, selecting an old chat through the paged endpoint
|
||||
used to show the transcript while the model only saw fresh context.
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
return
|
||||
session.history = [
|
||||
ChatMessage(role=m.role, content=m.content, metadata=_db_message_metadata(m) or None)
|
||||
for m in rows
|
||||
]
|
||||
session.message_count = len(session.history)
|
||||
|
||||
def _session_needs_db_history_hydration(session_id: str, total: int) -> bool:
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
return False
|
||||
return len(session.history or []) < int(total or 0)
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def get_session_history(
|
||||
request: Request,
|
||||
@@ -168,6 +206,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
.limit(page_limit)
|
||||
.all()
|
||||
)
|
||||
if _session_needs_db_history_hydration(session_id, total):
|
||||
full_rows = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
_hydrate_session_history_from_db(session_id, full_rows)
|
||||
history_dict = [
|
||||
entry for entry in (_db_history_entry(m) for m in rows)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
@@ -228,10 +274,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
if db_history:
|
||||
# Rebuild in-memory history from the full set so hidden
|
||||
# messages (e.g. compaction summaries) are kept for AI context.
|
||||
session.history = [
|
||||
ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata"))
|
||||
for m in db_history
|
||||
]
|
||||
_hydrate_session_history_from_db(session_id, db_messages)
|
||||
# Response excludes hidden messages, matching the in-memory path.
|
||||
history_dict = [
|
||||
m for m in db_history
|
||||
@@ -656,6 +699,55 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Topic analysis failed: {e}")
|
||||
|
||||
@router.get("/api/session/{session_id}/context")
|
||||
async def get_session_context_usage(request: Request, session_id: str) -> Dict[str, Any]:
|
||||
"""Return an estimated whole-chat context usage for the session's model.
|
||||
|
||||
Streaming footers report the prompt size for the last request. This
|
||||
endpoint estimates the persisted session context so the header can show
|
||||
when the whole chat is approaching compaction.
|
||||
"""
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
try:
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
|
||||
messages = session.get_context_messages()
|
||||
used = int(estimate_tokens(messages))
|
||||
ctx_len = int(get_context_length(session.endpoint_url, session.model) or 0)
|
||||
pct = round((used / ctx_len) * 100, 1) if ctx_len else 0.0
|
||||
pct = max(0.0, min(100.0, pct))
|
||||
visible_messages = sum(
|
||||
1 for m in session.history
|
||||
if not (getattr(m, "metadata", None) or {}).get("hidden")
|
||||
)
|
||||
compacted_messages = sum(
|
||||
1 for m in session.history
|
||||
if (getattr(m, "metadata", None) or {}).get("compacted")
|
||||
)
|
||||
can_compact = used > 0
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"model": session.model,
|
||||
"endpoint_url": session.endpoint_url,
|
||||
"used_tokens": used,
|
||||
"context_length": ctx_len,
|
||||
"context_percent": pct,
|
||||
"messages": visible_messages,
|
||||
"context_messages": len(messages),
|
||||
"compacted_messages": compacted_messages,
|
||||
"can_compact": can_compact,
|
||||
"should_compact": pct >= 70,
|
||||
"auto_compact_threshold": 85,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Context usage error {session_id}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
@router.post("/api/session/{session_id}/compact")
|
||||
async def compact_session(request: Request, session_id: str):
|
||||
"""Manually trigger context compaction for a session."""
|
||||
@@ -700,7 +792,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
compact_model = util_model or session.model
|
||||
compact_headers = util_headers if util_url else session.headers
|
||||
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT, normalize_compaction_summary
|
||||
compaction_count = sum(1 for m in session.history if isinstance(m, ChatMessage) and "[Conversation summary" in (m.content or ""))
|
||||
sys_prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace("{count}", str(len(older))).replace("{n}", str(compaction_count + 1))
|
||||
summary = await llm_call_async(
|
||||
@@ -712,6 +804,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
temperature=0.2, max_tokens=1024,
|
||||
headers=compact_headers, timeout=30,
|
||||
)
|
||||
summary = normalize_compaction_summary(summary)
|
||||
|
||||
# Replace session history: summary as system message + recent messages
|
||||
# System message holds the full summary for AI context
|
||||
|
||||
+17
-1
@@ -429,11 +429,27 @@ def setup_hwfit_routes():
|
||||
system["available_ram_gb"] = 0
|
||||
system["total_ram_gb"] = 0
|
||||
system = _apply_manual_hardware(system, manual_mode, manual_gpu_count, manual_vram_gb, manual_ram_gb, manual_backend)
|
||||
# Image models use a single GPU — always use per-GPU VRAM
|
||||
try:
|
||||
requested_gpu_count = int(gpu_count) if gpu_count != "" else None
|
||||
except ValueError:
|
||||
requested_gpu_count = None
|
||||
if requested_gpu_count == 0:
|
||||
# Respect the UI's RAM toggle. Before this route always rewrote the
|
||||
# system to best-single-GPU VRAM, so image rows never changed when
|
||||
# switching RAM/GPU.
|
||||
system["has_gpu"] = False
|
||||
system["gpu_vram_gb"] = 0
|
||||
system["gpu_count"] = 0
|
||||
system["gpu_only"] = False
|
||||
else:
|
||||
# Image diffusion backends generally use one device per pipeline,
|
||||
# so rank GPU mode against the best single GPU rather than total
|
||||
# multi-GPU VRAM.
|
||||
gpu_vrams = [float(g.get("vram_gb") or 0) for g in (system.get("gpus") or []) if isinstance(g, dict)]
|
||||
single_vram = max(gpu_vrams) if gpu_vrams else ((system.get("gpu_vram_gb") or 0) / max(system.get("gpu_count") or 1, 1))
|
||||
system["gpu_vram_gb"] = single_vram
|
||||
system["gpu_count"] = 1 if single_vram > 0 else 0
|
||||
system["gpu_only"] = True if single_vram > 0 else False
|
||||
results = rank_image_models(system, search=search or None, sort=sort)
|
||||
return {"system": system, "models": results}
|
||||
|
||||
|
||||
+96
-10
@@ -1310,6 +1310,56 @@ def _visible_models(cached_models, hidden_models, pinned_models=None):
|
||||
return [m for m in merged if m not in hidden]
|
||||
|
||||
|
||||
def _picker_requires_pinning(base_url: str, kind: str) -> bool:
|
||||
return _classify_endpoint(base_url, kind) == "api"
|
||||
|
||||
|
||||
def _has_explicit_pinned_models(ep) -> bool:
|
||||
"""Whether pinned_models was deliberately written for this endpoint.
|
||||
|
||||
API endpoints use pinned_models as an allow-list. An explicit empty JSON
|
||||
list means "show no models"; it must not fall back to the old hidden-list
|
||||
migration behavior.
|
||||
"""
|
||||
raw = getattr(ep, "pinned_models", None)
|
||||
return raw is not None and str(raw).strip() != ""
|
||||
|
||||
|
||||
def _legacy_visible_api_models(ep) -> List[str]:
|
||||
"""Return API models selected under the old hidden-list picker.
|
||||
|
||||
Before API endpoints switched to an explicit allow-list, selected models
|
||||
were represented as cached_models minus hidden_models. Existing OpenRouter
|
||||
rows can therefore have many checked models and an empty pinned_models
|
||||
field. Treat that old state as the initial pinned list so settings and chat
|
||||
agree after upgrade.
|
||||
"""
|
||||
return _visible_models(
|
||||
_cached_model_ids(ep),
|
||||
getattr(ep, "hidden_models", None),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
||||
"""Return model IDs that should appear in the picker for an endpoint.
|
||||
|
||||
API providers expose remote inventory from /v1/models. Treat that cache as
|
||||
inventory, not approval: only manually pinned API models should appear in
|
||||
the picker. Local/self-hosted endpoints keep the older hide-list behavior.
|
||||
"""
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
if _picker_requires_pinning(base_url, kind):
|
||||
if not _has_explicit_pinned_models(ep):
|
||||
pinned = _legacy_visible_api_models(ep) if _hidden_model_ids(ep) else []
|
||||
return pinned, pinned
|
||||
return _visible_models(
|
||||
_cached_model_ids(ep),
|
||||
getattr(ep, "hidden_models", None),
|
||||
pinned,
|
||||
), pinned
|
||||
|
||||
|
||||
def _api_key_fingerprint(api_key: Optional[str]) -> str:
|
||||
"""Stable, non-secret label for distinguishing same-URL credentials."""
|
||||
key = (api_key or "").strip()
|
||||
@@ -1508,24 +1558,18 @@ def setup_model_routes(model_discovery):
|
||||
for ep in endpoints:
|
||||
base = _normalize_base(ep.base_url)
|
||||
provider = _safe_detect_provider(base)
|
||||
# Merge cached + pinned models, then filter out hidden ones
|
||||
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
||||
model_ids = _visible_models(
|
||||
_cached_model_ids(ep),
|
||||
ep.hidden_models,
|
||||
getattr(ep, "pinned_models", None),
|
||||
)
|
||||
# Build correct URL based on provider
|
||||
chat_url = build_chat_url(base)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
category = _classify_endpoint(base, kind)
|
||||
model_ids, pinned = _picker_models_for_endpoint(ep, base, kind)
|
||||
|
||||
if model_ids:
|
||||
curated_key = _match_provider_curated(base, None)
|
||||
curated, extra = _curate_models(model_ids, curated_key)
|
||||
# Pinned models are admin-selected — they always belong in the
|
||||
# primary curated list, not buried in extras.
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
for m in pinned:
|
||||
if m not in curated:
|
||||
curated.append(m)
|
||||
@@ -1887,18 +1931,24 @@ def setup_model_routes(model_discovery):
|
||||
_invalidate_models_cache()
|
||||
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
||||
results = []
|
||||
upgraded_legacy_pins = False
|
||||
for r in rows:
|
||||
all_models = _cached_model_ids(r)
|
||||
hidden = _hidden_model_ids(r)
|
||||
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
||||
visible = _visible_models(all_models, r.hidden_models, pinned)
|
||||
# Keep the list route cache-only. It feeds Settings →
|
||||
# Added Models and must render immediately; explicit
|
||||
# Refresh/Probe endpoints do the network work.
|
||||
status = "online" if (all_models or pinned) else ("empty" if r.is_enabled else "offline")
|
||||
ping = None
|
||||
base = _normalize_base(r.base_url)
|
||||
kind = _effective_endpoint_kind(r, base)
|
||||
visible, pinned = _picker_models_for_endpoint(r, base, kind)
|
||||
if _picker_requires_pinning(base, kind) and pinned and not _has_explicit_pinned_models(r):
|
||||
r.pinned_models = json.dumps(pinned)
|
||||
upgraded_legacy_pins = True
|
||||
model_inventory_count = len(_merge_model_ids(all_models, pinned))
|
||||
picker_requires_pinning = _picker_requires_pinning(base, kind)
|
||||
status = "online" if (all_models or visible or pinned) else ("empty" if r.is_enabled else "offline")
|
||||
results.append({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
@@ -1907,6 +1957,8 @@ def setup_model_routes(model_discovery):
|
||||
"api_key_fingerprint": _api_key_fingerprint(r.api_key),
|
||||
"is_enabled": r.is_enabled,
|
||||
"models": visible,
|
||||
"model_count": model_inventory_count,
|
||||
"picker_requires_pinning": picker_requires_pinning,
|
||||
"pinned_models": pinned,
|
||||
"hidden_count": len(hidden),
|
||||
"online": status != "offline",
|
||||
@@ -1920,6 +1972,9 @@ def setup_model_routes(model_discovery):
|
||||
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
|
||||
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
|
||||
})
|
||||
if upgraded_legacy_pins:
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
return results
|
||||
finally:
|
||||
db.close()
|
||||
@@ -2023,6 +2078,10 @@ def setup_model_routes(model_discovery):
|
||||
if refresh_timeout is not None:
|
||||
existing.model_refresh_timeout = refresh_timeout
|
||||
changed = True
|
||||
incoming_model_type = (model_type or "").strip() or "llm"
|
||||
if incoming_model_type and (getattr(existing, "model_type", None) or "llm") != incoming_model_type:
|
||||
existing.model_type = incoming_model_type
|
||||
changed = True
|
||||
if api_key.strip() and not existing.api_key:
|
||||
existing.api_key = api_key.strip()
|
||||
changed = True
|
||||
@@ -2255,9 +2314,10 @@ def setup_model_routes(model_discovery):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
hidden = _hidden_model_ids(ep)
|
||||
all_models = _cached_model_ids(ep)
|
||||
if refresh:
|
||||
base = _normalize_base(ep.base_url)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
picker_requires_pinning = _picker_requires_pinning(base, kind)
|
||||
if refresh:
|
||||
category = _classify_endpoint(base, kind)
|
||||
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
||||
try:
|
||||
@@ -2276,6 +2336,8 @@ def setup_model_routes(model_discovery):
|
||||
response.headers["X-Model-Refresh-Status"] = "failed"
|
||||
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
if picker_requires_pinning and not _has_explicit_pinned_models(ep):
|
||||
pinned = _legacy_visible_api_models(ep)
|
||||
pinned_set = set(pinned)
|
||||
return [
|
||||
{
|
||||
@@ -2283,6 +2345,7 @@ def setup_model_routes(model_discovery):
|
||||
"display": m.split("/")[-1],
|
||||
"is_hidden": m in hidden,
|
||||
"is_pinned": m in pinned_set,
|
||||
"picker_requires_pinning": picker_requires_pinning,
|
||||
}
|
||||
for m in _merge_model_ids(all_models, pinned)
|
||||
]
|
||||
@@ -2311,10 +2374,27 @@ def setup_model_routes(model_discovery):
|
||||
hidden = body.get("hidden")
|
||||
if not isinstance(hidden, list):
|
||||
raise HTTPException(400, "hidden must be a list of model IDs")
|
||||
base = _normalize_base(ep.base_url)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
if _picker_requires_pinning(base, kind):
|
||||
# Compatibility for older/admin UI paths that still submit
|
||||
# the previous hide-list shape. API pickers are allow-lists:
|
||||
# convert "unchecked models" into an explicit pinned list so
|
||||
# Settings summary, /api/models, and chat agree.
|
||||
selected = _visible_models(_cached_model_ids(ep), hidden, None)
|
||||
ep.pinned_models = json.dumps(selected)
|
||||
ep.hidden_models = None
|
||||
else:
|
||||
ep.hidden_models = json.dumps(hidden) if hidden else None
|
||||
# Accept either "pinned" or "pinned_models" for the manual IDs list.
|
||||
if "pinned_models" in body or "pinned" in body:
|
||||
pinned = _normalize_model_ids(body.get("pinned_models", body.get("pinned")))
|
||||
base = _normalize_base(ep.base_url)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
if _picker_requires_pinning(base, kind):
|
||||
ep.pinned_models = json.dumps(pinned)
|
||||
ep.hidden_models = None
|
||||
else:
|
||||
ep.pinned_models = json.dumps(pinned) if pinned else None
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
@@ -2468,6 +2548,12 @@ def setup_model_routes(model_discovery):
|
||||
ep.model_type = body["model_type"].strip() or ep.model_type
|
||||
if "pinned_models" in body:
|
||||
_pinned = _normalize_model_ids(body["pinned_models"])
|
||||
_base_for_pins = _normalize_base(ep.base_url)
|
||||
_kind_for_pins = _effective_endpoint_kind(ep, _base_for_pins)
|
||||
if _picker_requires_pinning(_base_for_pins, _kind_for_pins):
|
||||
ep.pinned_models = json.dumps(_pinned)
|
||||
ep.hidden_models = None
|
||||
else:
|
||||
ep.pinned_models = json.dumps(_pinned) if _pinned else None
|
||||
if "endpoint_kind" in body:
|
||||
ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind"))
|
||||
|
||||
@@ -12,6 +12,7 @@ from core.models import ChatMessage
|
||||
from src.request_models import SessionResponse
|
||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
||||
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||
from src.session_actions import is_session_recently_active
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
|
||||
@@ -220,6 +221,7 @@ def setup_session_routes(
|
||||
@router.get("/sessions")
|
||||
def list_sessions(request: Request):
|
||||
user = effective_user(request)
|
||||
active_incognito_id = str(request.query_params.get("active_incognito_id") or "").strip()
|
||||
# Lazy purge: incognito sessions are ephemeral by design — wipe leftovers
|
||||
# from the DB and session_manager so they vanish on the next page refresh.
|
||||
# BUT: skip sessions that were created within the last 10 minutes.
|
||||
@@ -240,6 +242,8 @@ def setup_session_routes(
|
||||
DbSession.created_at < _cutoff,
|
||||
).all()
|
||||
for _g in _ghosts:
|
||||
if active_incognito_id and _g.id == active_incognito_id:
|
||||
continue
|
||||
_purge_db.query(_DbMsg).filter(_DbMsg.session_id == _g.id).delete()
|
||||
_purge_db.delete(_g)
|
||||
if hasattr(session_manager, "delete_session"):
|
||||
@@ -641,13 +645,43 @@ def setup_session_routes(
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from core.database import ChatMessage as DbChatMessage
|
||||
session_ids = [row[0] for row in db.query(DbSession.id).all()]
|
||||
count = db.query(DbSession).count()
|
||||
image_ids: set[str] = set()
|
||||
filenames: set[str] = set()
|
||||
for sid in session_ids:
|
||||
ids, names = session_image_refs(db, sid)
|
||||
image_ids.update(ids)
|
||||
filenames.update(names)
|
||||
image_query = db.query(GalleryImage).filter(GalleryImage.session_id.in_(session_ids)) if session_ids else db.query(GalleryImage).filter(False)
|
||||
if image_ids or filenames:
|
||||
from sqlalchemy import or_
|
||||
clauses = []
|
||||
if session_ids:
|
||||
clauses.append(GalleryImage.session_id.in_(session_ids))
|
||||
if image_ids:
|
||||
clauses.append(GalleryImage.id.in_(list(image_ids)))
|
||||
if filenames:
|
||||
clauses.append(GalleryImage.filename.in_(list(filenames)))
|
||||
image_query = db.query(GalleryImage).filter(or_(*clauses))
|
||||
images = image_query.all()
|
||||
removed_images = 0
|
||||
for img in images:
|
||||
img.is_active = False
|
||||
if img.filename:
|
||||
path = _generated_image_path_for_cleanup(img.filename)
|
||||
if path and path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
except Exception as exc:
|
||||
logger.warning("Could not remove generated image %s during all-session delete: %s", img.filename, exc)
|
||||
removed_images += 1
|
||||
db.query(DbChatMessage).delete()
|
||||
db.query(DbSession).delete()
|
||||
db.commit()
|
||||
session_manager.sessions.clear()
|
||||
logger.info(f"Admin deleted all {count} sessions")
|
||||
return {"status": "deleted", "count": count}
|
||||
logger.info(f"Admin deleted all {count} sessions and {removed_images} linked images")
|
||||
return {"status": "deleted", "count": count, "images_deleted": removed_images}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting all sessions: {e}")
|
||||
|
||||
+197
-4
@@ -157,6 +157,7 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
||||
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
|
||||
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
|
||||
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
|
||||
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
|
||||
|
||||
if name == "vllm":
|
||||
return bool(binaries.get("vllm"))
|
||||
@@ -166,11 +167,43 @@ def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
||||
return bool(dists.get("sglang") or modules.get("sglang", {}).get("real_module"))
|
||||
if name == "mlx_lm":
|
||||
return bool(dists.get("mlx-lm") or modules.get("mlx_lm", {}).get("real_module"))
|
||||
if name == "mflux":
|
||||
return bool(
|
||||
dists.get("mflux")
|
||||
or modules.get("mflux", {}).get("real_module")
|
||||
or binaries.get("mflux-generate-qwen")
|
||||
or binaries.get("mflux-generate")
|
||||
)
|
||||
if name == "boogu_image_mlx":
|
||||
return bool(
|
||||
dists.get("boogu-image-mlx")
|
||||
or modules.get("boogu_image_mlx", {}).get("real_module")
|
||||
)
|
||||
if name == "mlx_lama_swift":
|
||||
return bool(
|
||||
(binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"))
|
||||
and (files.get("mlx.metallib") or files.get("default.metallib"))
|
||||
)
|
||||
if name == "mlx_ddcolor_swift":
|
||||
return bool(
|
||||
(binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"))
|
||||
and (files.get("mlx.metallib") or files.get("default.metallib"))
|
||||
)
|
||||
if name == "diffusers":
|
||||
return bool(
|
||||
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
||||
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
||||
)
|
||||
if name == "krea_diffusers":
|
||||
return bool(
|
||||
(dists.get("diffusers") or modules.get("diffusers", {}).get("real_module"))
|
||||
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
||||
)
|
||||
if name == "sam_mask":
|
||||
return bool(
|
||||
(dists.get("transformers") or modules.get("transformers", {}).get("real_module"))
|
||||
and (dists.get("torch") or modules.get("torch", {}).get("real_module"))
|
||||
)
|
||||
if name == "hf_transfer":
|
||||
return bool(
|
||||
dists.get("hf-transfer")
|
||||
@@ -183,6 +216,7 @@ def _package_status_note(name: str, probe: dict) -> str:
|
||||
binaries = probe.get("binaries") if isinstance(probe.get("binaries"), dict) else {}
|
||||
modules = probe.get("modules") if isinstance(probe.get("modules"), dict) else {}
|
||||
dists = probe.get("dists") if isinstance(probe.get("dists"), dict) else {}
|
||||
files = probe.get("files") if isinstance(probe.get("files"), dict) else {}
|
||||
module = modules.get(name) if isinstance(modules.get(name), dict) else {}
|
||||
locations = module.get("locations") or []
|
||||
if name == "vllm":
|
||||
@@ -212,10 +246,53 @@ def _package_status_note(name: str, probe: dict) -> str:
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}"
|
||||
return "Diffusers serving needs both diffusers and torch."
|
||||
if name == "krea_diffusers":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"Latest Diffusers runtime: diffusers {dists.get('diffusers', 'available')} with torch {dists.get('torch', 'available')}. Use Update/Reinstall to pull latest Diffusers from Git."
|
||||
return "Some newer image models need torch plus latest Diffusers from Git."
|
||||
if name == "sam_mask":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"SAM object masks: transformers {dists.get('transformers', 'available')} with torch {dists.get('torch', 'available')}"
|
||||
return "SAM click/object mask selection needs transformers and torch."
|
||||
if name == "mlx_lm":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"MLX LM {dists.get('mlx-lm', 'available')}"
|
||||
return "MLX serving needs mlx-lm on an Apple Silicon Mac."
|
||||
if name == "mflux":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
parts = []
|
||||
if dists.get("mflux"):
|
||||
parts.append(f"mflux {dists['mflux']}")
|
||||
if binaries.get("mflux-generate-qwen"):
|
||||
parts.append(f"Qwen CLI: {binaries['mflux-generate-qwen']}")
|
||||
if binaries.get("mflux-generate"):
|
||||
parts.append(f"Flux CLI: {binaries['mflux-generate']}")
|
||||
return "; ".join(parts) if parts else "mflux available"
|
||||
return "MLX image serving needs mflux on an Apple Silicon Mac."
|
||||
if name == "boogu_image_mlx":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
return f"Boogu MLX pipeline {dists.get('boogu-image-mlx', 'available')}"
|
||||
return "Boogu image models need boogu-image-mlx on an Apple Silicon Mac."
|
||||
if name == "mlx_lama_swift":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
found = [
|
||||
binaries.get("odysseus-mlx-inpaint"),
|
||||
binaries.get("mlx-lama-serve"),
|
||||
]
|
||||
return f"LaMa/MI-GAN Swift MLX runner: {next((p for p in found if p), 'available')}"
|
||||
if binaries.get("odysseus-mlx-inpaint") or binaries.get("mlx-lama-serve"):
|
||||
return "LaMa/MI-GAN Swift runner is installed, but mlx.metallib is missing next to the runner."
|
||||
return "LaMa/MI-GAN inpainting models need an Odysseus-compatible mlx-lama-swift bridge on an Apple Silicon Mac."
|
||||
if name == "mlx_ddcolor_swift":
|
||||
if _package_installed_from_probe(name, probe):
|
||||
found = [
|
||||
binaries.get("odysseus-mlx-colorize"),
|
||||
binaries.get("mlx-ddcolor-serve"),
|
||||
]
|
||||
return f"DDColor Swift MLX runner: {next((p for p in found if p), 'available')}"
|
||||
if binaries.get("odysseus-mlx-colorize") or binaries.get("mlx-ddcolor-serve"):
|
||||
return "DDColor Swift runner is installed, but mlx.metallib is missing next to the runner."
|
||||
return "DDColor colorization models need an Odysseus-compatible mlx-ddcolor-swift bridge on an Apple Silicon Mac."
|
||||
if name in dists:
|
||||
return f"{name} {dists[name]}"
|
||||
return ""
|
||||
@@ -314,12 +391,22 @@ dist_names={{
|
||||
'llama_cpp':['llama-cpp-python'],
|
||||
'sglang':['sglang'],
|
||||
'mlx_lm':['mlx-lm'],
|
||||
'mlx_vlm':['mlx-vlm'],
|
||||
'mflux':['mflux'],
|
||||
'boogu_image_mlx':['boogu-image-mlx'],
|
||||
'mlx_lama_swift':[],
|
||||
'mlx_ddcolor_swift':[],
|
||||
'diffusers':['diffusers','torch'],
|
||||
'krea_diffusers':['diffusers','torch'],
|
||||
'sam_mask':['transformers','torch'],
|
||||
'hf_transfer':['hf-transfer','hf_transfer'],
|
||||
}}
|
||||
bin_names={{
|
||||
'vllm':['vllm'],
|
||||
'llama_cpp':['llama-server'],
|
||||
'mflux':['mflux-generate-qwen', 'mflux-generate'],
|
||||
'mlx_lama_swift':['odysseus-mlx-inpaint', 'mlx-lama-serve'],
|
||||
'mlx_ddcolor_swift':['odysseus-mlx-colorize', 'mlx-ddcolor-serve'],
|
||||
'tmux':['tmux'],
|
||||
}}
|
||||
|
||||
@@ -372,7 +459,19 @@ def probe(n):
|
||||
mods['torch'] = mod_status('torch')
|
||||
dists = dist_status(dist_names.get(n, [n]))
|
||||
bins = {{b: shutil.which(b) for b in bin_names.get(n, [])}}
|
||||
return {{'modules': mods, 'dists': dists, 'binaries': bins}}
|
||||
files = {{}}
|
||||
if n in ('mlx_lama_swift', 'mlx_ddcolor_swift'):
|
||||
for key in ('mlx.metallib', 'default.metallib'):
|
||||
found = None
|
||||
for b in bins.values():
|
||||
if not b:
|
||||
continue
|
||||
p = os.path.join(os.path.dirname(b), key)
|
||||
if os.path.exists(p):
|
||||
found = p
|
||||
break
|
||||
files[key] = found
|
||||
return {{'modules': mods, 'dists': dists, 'binaries': bins, 'files': files}}
|
||||
|
||||
print(json.dumps({{n: probe(n) for n in names}}))
|
||||
"""
|
||||
@@ -1088,6 +1187,8 @@ def setup_shell_routes() -> APIRouter:
|
||||
ssh_port: str | None = None,
|
||||
venv: str | None = None,
|
||||
backend: str | None = None,
|
||||
platform: str | None = None,
|
||||
model_hint: str | None = None,
|
||||
):
|
||||
"""Check which optional packages are installed.
|
||||
|
||||
@@ -1104,6 +1205,14 @@ def setup_shell_routes() -> APIRouter:
|
||||
import site
|
||||
import sys
|
||||
|
||||
platform_l = (platform or "").strip().lower()
|
||||
model_hint_l = (model_hint or "").strip().lower()
|
||||
has_krea_model = "krea" in model_hint_l
|
||||
has_lama_mlx_model = any(
|
||||
key in model_hint_l
|
||||
for key in ("lama", "mi-gan", "migan", "inpainting-mlx")
|
||||
)
|
||||
has_ddcolor_mlx_model = "ddcolor" in model_hint_l
|
||||
_prepend_user_install_bins_to_path()
|
||||
importlib.invalidate_caches()
|
||||
try:
|
||||
@@ -1158,7 +1267,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"name": "hf_transfer",
|
||||
"pip": "hf_transfer",
|
||||
"desc": "Fast model downloads from HuggingFace",
|
||||
"category": "LLM",
|
||||
"category": "Tools",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
@@ -1210,8 +1319,52 @@ def setup_shell_routes() -> APIRouter:
|
||||
# ── Image ── editor + diffusion model serving
|
||||
{
|
||||
"name": "diffusers",
|
||||
"pip": "diffusers[torch]",
|
||||
"desc": "Image generation/editing pipelines (SD, Flux) with PyTorch",
|
||||
"pip": "diffusers[torch] torchvision accelerate scipy python-multipart",
|
||||
"desc": "Image generation/editing pipelines with PyTorch and Diffusers",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "krea_diffusers",
|
||||
"pip": "git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart",
|
||||
"desc": "Latest Diffusers from Git for newly released image pipelines",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "mflux",
|
||||
"pip": "mflux",
|
||||
"desc": "MLX image generation runtime for Apple Silicon models like Qwen Image",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "boogu_image_mlx",
|
||||
"pip": "git+https://github.com/xocialize/boogu-image-mlx.git",
|
||||
"desc": "MLX image generation pipeline for Boogu Image models on Apple Silicon",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "mlx_lama_swift",
|
||||
"pip": "",
|
||||
"desc": "Swift MLX runtime for LaMa / MI-GAN inpainting and object removal",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
"install_hint": "Build an Odysseus-compatible mlx-lama-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-inpaint or mlx-lama-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable image-edit CLI.",
|
||||
},
|
||||
{
|
||||
"name": "mlx_ddcolor_swift",
|
||||
"pip": "",
|
||||
"desc": "Swift MLX runtime for DDColor automatic image colorization",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
"install_hint": "Build an Odysseus-compatible mlx-ddcolor-swift bridge on the selected Apple Silicon Mac and put odysseus-mlx-colorize or mlx-ddcolor-serve on PATH. Upstream currently ships Swift libraries plus smoke executables, not a stable colorize CLI.",
|
||||
},
|
||||
{
|
||||
"name": "mlx_vlm",
|
||||
"pip": "mlx-vlm",
|
||||
"desc": "MLX-VLM backbone used by HiDream image models on Apple Silicon",
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
@@ -1222,6 +1375,13 @@ def setup_shell_routes() -> APIRouter:
|
||||
"category": "Image",
|
||||
"target": "remote",
|
||||
},
|
||||
{
|
||||
"name": "sam_mask",
|
||||
"pip": "torch torchvision transformers accelerate pillow",
|
||||
"desc": "Neutral click/box/object segmentation masks for the image editor",
|
||||
"category": "Image",
|
||||
"target": "local",
|
||||
},
|
||||
{
|
||||
"name": "rembg",
|
||||
"pip": "rembg[gpu]",
|
||||
@@ -1251,6 +1411,21 @@ def setup_shell_routes() -> APIRouter:
|
||||
for pkg in packages:
|
||||
pkg.setdefault("install_cmd", None)
|
||||
pkg.setdefault("update_cmd", None)
|
||||
if not has_krea_model:
|
||||
packages = [
|
||||
p for p in packages
|
||||
if p.get("name") not in {"krea_diffusers", "transformers"}
|
||||
]
|
||||
if not has_lama_mlx_model:
|
||||
packages = [
|
||||
p for p in packages
|
||||
if p.get("name") != "mlx_lama_swift"
|
||||
]
|
||||
if not has_ddcolor_mlx_model:
|
||||
packages = [
|
||||
p for p in packages
|
||||
if p.get("name") != "mlx_ddcolor_swift"
|
||||
]
|
||||
# Remote check: for remote-target packages, probe the selected server's
|
||||
# venv over SSH so a remote `pip install` actually reflects here.
|
||||
remote_status: dict = {}
|
||||
@@ -1381,8 +1556,22 @@ def setup_shell_routes() -> APIRouter:
|
||||
target_os_id = ""
|
||||
if sys.platform == "darwin":
|
||||
target_os_id = "macos"
|
||||
if not target_os_id and platform_l in {"darwin", "macos", "mac"}:
|
||||
target_os_id = "macos"
|
||||
|
||||
for pkg in packages:
|
||||
if pkg.get("name") in {"mflux", "boogu_image_mlx", "mlx_vlm", "mlx_lama_swift", "mlx_ddcolor_swift"}:
|
||||
is_apple_target = target_os_id == "macos" or (
|
||||
not host and IS_APPLE_SILICON
|
||||
)
|
||||
known_non_apple_target = bool(target_os_id and target_os_id != "macos") or (
|
||||
not host and not IS_APPLE_SILICON
|
||||
)
|
||||
pkg["applicable"] = is_apple_target
|
||||
if known_non_apple_target:
|
||||
pkg["installed"] = None
|
||||
pkg["status_note"] = "Only relevant for Apple Silicon / MLX image serving."
|
||||
continue
|
||||
on_remote = bool(host and pkg.get("target") == "remote")
|
||||
probe = None
|
||||
if on_remote:
|
||||
@@ -1588,6 +1777,10 @@ def setup_shell_routes() -> APIRouter:
|
||||
"sglang[all]",
|
||||
"diffusers",
|
||||
"diffusers[torch]",
|
||||
"git+https://github.com/huggingface/diffusers.git",
|
||||
"mflux",
|
||||
"git+https://github.com/xocialize/boogu-image-mlx.git",
|
||||
"mlx-vlm",
|
||||
"transformers",
|
||||
"TTS",
|
||||
"bark",
|
||||
|
||||
+422
-86
@@ -26,13 +26,14 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import torch
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from pydantic import BaseModel
|
||||
@@ -44,6 +45,7 @@ _pipe = None
|
||||
_model_id = ""
|
||||
DTYPE_MAP = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
||||
_args = None
|
||||
_PROGRESS = {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -112,6 +114,77 @@ def _configure_security_middleware(application, allowed_hosts, allowed_origins):
|
||||
_configure_security_middleware(app, _DEFAULT_ALLOWED_HOSTS, _DEFAULT_CORS_ORIGINS)
|
||||
|
||||
|
||||
def _start_progress(request_id: str, total_steps: int, prompt: str, kind: str) -> str:
|
||||
rid = (request_id or "").strip() or uuid.uuid4().hex
|
||||
_PROGRESS[rid] = {
|
||||
"id": rid,
|
||||
"kind": kind,
|
||||
"status": "running",
|
||||
"step": 0,
|
||||
"total": max(1, int(total_steps or 1)),
|
||||
"percent": 0,
|
||||
"prompt": (prompt or "")[:120],
|
||||
"started_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
return rid
|
||||
|
||||
|
||||
def _update_progress(request_id: str, step: int, total_steps: int | None = None, status: str = "running"):
|
||||
if not request_id:
|
||||
return
|
||||
item = _PROGRESS.get(request_id)
|
||||
if not item:
|
||||
return
|
||||
total = max(1, int(total_steps or item.get("total") or 1))
|
||||
current = max(0, min(int(step or 0), total))
|
||||
item.update({
|
||||
"status": status,
|
||||
"step": current,
|
||||
"total": total,
|
||||
"percent": round((current / total) * 100, 1),
|
||||
"updated_at": time.time(),
|
||||
})
|
||||
|
||||
|
||||
def _finish_progress(request_id: str, status: str = "done", error: str = ""):
|
||||
if not request_id:
|
||||
return
|
||||
item = _PROGRESS.get(request_id)
|
||||
if not item:
|
||||
return
|
||||
total = max(1, int(item.get("total") or 1))
|
||||
item.update({
|
||||
"status": status,
|
||||
"step": total if status == "done" else item.get("step", 0),
|
||||
"total": total,
|
||||
"percent": 100 if status == "done" else item.get("percent", 0),
|
||||
"error": error,
|
||||
"updated_at": time.time(),
|
||||
})
|
||||
|
||||
|
||||
def _run_pipeline_with_progress(pipe, request_id: str, total_steps: int, **kwargs):
|
||||
def step_end_callback(_pipe, step, timestep, callback_kwargs):
|
||||
_update_progress(request_id, int(step) + 1, total_steps)
|
||||
return callback_kwargs
|
||||
|
||||
def legacy_callback(step, timestep, latents):
|
||||
_update_progress(request_id, int(step) + 1, total_steps)
|
||||
|
||||
try:
|
||||
return pipe(callback_on_step_end=step_end_callback, **kwargs)
|
||||
except TypeError as exc:
|
||||
if "callback_on_step_end" not in str(exc):
|
||||
raise
|
||||
try:
|
||||
return pipe(callback=legacy_callback, callback_steps=1, **kwargs)
|
||||
except TypeError as exc:
|
||||
if "callback" not in str(exc) and "callback_steps" not in str(exc):
|
||||
raise
|
||||
return pipe(**kwargs)
|
||||
|
||||
|
||||
class ImageRequest(BaseModel):
|
||||
model: str = ""
|
||||
prompt: str
|
||||
@@ -119,10 +192,71 @@ class ImageRequest(BaseModel):
|
||||
size: str = "1024x1024"
|
||||
quality: str = "medium"
|
||||
response_format: str = "b64_json"
|
||||
request_id: str = ""
|
||||
|
||||
|
||||
def _parse_size(size: str) -> tuple[int, int]:
|
||||
try:
|
||||
w, h = (size or "").split("x")
|
||||
return int(w), int(h)
|
||||
except Exception:
|
||||
return _args.width, _args.height
|
||||
|
||||
|
||||
def _quality_steps(quality: str) -> int:
|
||||
default_steps = _args.steps or 8
|
||||
steps_map = {"low": 4, "medium": default_steps, "high": 20, "auto": 12}
|
||||
return steps_map.get(quality, default_steps)
|
||||
|
||||
|
||||
def _guidance_scale() -> float:
|
||||
return float(_args.guidance_scale)
|
||||
|
||||
|
||||
def _default_negative_prompt() -> str | None:
|
||||
value = (_args.negative_prompt or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _pipeline_accepts_arg(name: str) -> bool:
|
||||
try:
|
||||
import inspect
|
||||
sig = inspect.signature(_pipe.__call__)
|
||||
return name in sig.parameters
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _pipeline_call_kwargs(**kwargs) -> dict:
|
||||
"""Filter kwargs to the active pipeline signature.
|
||||
|
||||
Diffusers/community pipelines are not consistent: ordinary img2img
|
||||
usually accepts `image`, while instruction-edit models such as OmniGen2
|
||||
accept `input_images` plus model-specific guidance fields. Filtering keeps
|
||||
the server generic and avoids hardcoding repo IDs.
|
||||
"""
|
||||
try:
|
||||
import inspect
|
||||
sig = inspect.signature(_pipe.__call__)
|
||||
params = sig.parameters
|
||||
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||
return {k: v for k, v in kwargs.items() if v is not None}
|
||||
return {k: v for k, v in kwargs.items() if v is not None and k in params}
|
||||
except Exception:
|
||||
return {k: v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
|
||||
def _image_response(images) -> dict:
|
||||
data = []
|
||||
for img in images:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
data.append({"b64_json": base64.b64encode(buf.getvalue()).decode()})
|
||||
return {"created": int(time.time()), "data": data}
|
||||
|
||||
|
||||
def _fix_meta_tensors(pipe, dtype):
|
||||
"""Replace any meta tensors with real zero tensors on CPU so .to(cuda) works."""
|
||||
"""Replace any meta tensors with real zero tensors on CPU so .to(device) works."""
|
||||
for name, component in pipe.components.items():
|
||||
if not hasattr(component, 'parameters'):
|
||||
continue
|
||||
@@ -142,6 +276,69 @@ def _fix_meta_tensors(pipe, dtype):
|
||||
logger.info(f" Fixed {fixed} meta tensors in {name}")
|
||||
|
||||
|
||||
def _target_device() -> str:
|
||||
"""Best available torch device for Diffusers on this host."""
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _can_cpu_offload(device: str) -> bool:
|
||||
# Diffusers CPU offload helpers are CUDA/accelerate-oriented. On Apple
|
||||
# Silicon they either no-op poorly or fail; MPS should use direct .to("mps").
|
||||
return device == "cuda"
|
||||
|
||||
|
||||
def _load_omnigen2_pipeline(model_path: str, torch_dtype, target_device: str, use_offload: bool) -> bool:
|
||||
"""Load OmniGen2 from the official repo package.
|
||||
|
||||
OmniGen2 publishes a Diffusers-style model_index.json, but current
|
||||
public diffusers builds do not expose OmniGen2Pipeline as a stock class.
|
||||
The official examples import the pipeline from the cloned `omnigen2`
|
||||
package, so support that path without hardcoding any private model.
|
||||
"""
|
||||
global _pipe
|
||||
try:
|
||||
from omnigen2.pipelines.omnigen2.pipeline_omnigen2 import OmniGen2Pipeline
|
||||
from omnigen2.models.transformers.transformer_omnigen2 import OmniGen2Transformer2DModel
|
||||
except Exception as exc:
|
||||
logger.warning("OmniGen2 package import failed: %s", exc)
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info("Loading OmniGen2 pipeline via official omnigen2 package")
|
||||
pipe = OmniGen2Pipeline.from_pretrained(
|
||||
model_path,
|
||||
torch_dtype=torch_dtype,
|
||||
trust_remote_code=True,
|
||||
)
|
||||
pipe.transformer = OmniGen2Transformer2DModel.from_pretrained(
|
||||
model_path,
|
||||
subfolder="transformer",
|
||||
torch_dtype=torch_dtype,
|
||||
)
|
||||
if use_offload and _can_cpu_offload(target_device):
|
||||
pipe.enable_model_cpu_offload()
|
||||
logger.info("Loaded OmniGen2 with CPU offload")
|
||||
else:
|
||||
pipe = pipe.to(target_device)
|
||||
logger.info("Loaded OmniGen2 on %s", target_device)
|
||||
_pipe = pipe
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Official OmniGen2 loader failed: %s", exc)
|
||||
_pipe = None
|
||||
return False
|
||||
|
||||
|
||||
def load_model():
|
||||
global _pipe, _model_id
|
||||
import diffusers
|
||||
@@ -151,8 +348,12 @@ def load_model():
|
||||
dtype_map = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
||||
torch_dtype = dtype_map.get(_args.dtype, torch.bfloat16)
|
||||
use_offload = _args.cpu_offload
|
||||
target_device = _target_device()
|
||||
if target_device != "cuda" and use_offload:
|
||||
logger.warning("CPU offload requested but %s is the active device; using direct device placement instead", target_device)
|
||||
use_offload = False
|
||||
|
||||
logger.info(f"Loading model from {model_path} (dtype={_args.dtype}, offload={use_offload})...")
|
||||
logger.info(f"Loading model from {model_path} (dtype={_args.dtype}, offload={use_offload}, device={target_device})...")
|
||||
|
||||
# Ensure HF token is available for gated repos
|
||||
_hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
||||
@@ -207,15 +408,37 @@ def load_model():
|
||||
except Exception as e:
|
||||
logger.debug(f"GPU cache clear failed: {e}")
|
||||
|
||||
loaded = False
|
||||
if cls_name_from_index == "OmniGen2Pipeline" or "omnigen2" in str(model_path).lower():
|
||||
loaded = _load_omnigen2_pipeline(model_path, torch_dtype, target_device, use_offload)
|
||||
|
||||
def _load_pipe(cls, name):
|
||||
"""Try loading pipeline, handling meta tensor issues."""
|
||||
global _pipe
|
||||
|
||||
# First try normal load
|
||||
try:
|
||||
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
|
||||
kwargs = {"torch_dtype": torch_dtype}
|
||||
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||
kwargs["trust_remote_code"] = True
|
||||
_pipe = cls.from_pretrained(model_path, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(f"{name} from_pretrained failed: {e}")
|
||||
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||
try:
|
||||
logger.info(f"Retrying {name} with custom_pipeline={model_path}")
|
||||
_pipe = cls.from_pretrained(
|
||||
model_path,
|
||||
torch_dtype=torch_dtype,
|
||||
custom_pipeline=model_path,
|
||||
trust_remote_code=True,
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.warning(f"{name} custom_pipeline retry failed: {e2}")
|
||||
_pipe = None
|
||||
_cleanup()
|
||||
return False
|
||||
else:
|
||||
_pipe = None
|
||||
_cleanup()
|
||||
return False
|
||||
@@ -223,7 +446,7 @@ def load_model():
|
||||
# Materialize any meta tensors before moving to device
|
||||
_fix_meta_tensors(_pipe, torch_dtype)
|
||||
|
||||
if use_offload:
|
||||
if use_offload and _can_cpu_offload(target_device):
|
||||
try:
|
||||
_pipe.enable_model_cpu_offload()
|
||||
logger.info(f"Loaded as {name} with CPU offload")
|
||||
@@ -234,24 +457,27 @@ def load_model():
|
||||
_cleanup()
|
||||
return False
|
||||
|
||||
# Try full CUDA
|
||||
# Try direct device placement
|
||||
try:
|
||||
_pipe = _pipe.to("cuda")
|
||||
logger.info(f"Loaded as {name} on CUDA")
|
||||
_pipe = _pipe.to(target_device)
|
||||
logger.info(f"Loaded as {name} on {target_device}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"{name} + .to(cuda) failed: {e}")
|
||||
logger.warning(f"{name} + .to({target_device}) failed: {e}")
|
||||
_pipe = None
|
||||
_cleanup()
|
||||
|
||||
if not use_offload:
|
||||
logger.error(f"{name} doesn't fit in VRAM. Use --cpu-offload to enable offloading.")
|
||||
logger.error(f"{name} could not be placed on {target_device}. On CUDA, try --cpu-offload; on Apple Silicon try a smaller model or lower resolution.")
|
||||
return False
|
||||
|
||||
# OOM — reload and try with CPU offload
|
||||
try:
|
||||
logger.info(f"Reloading {name} with CPU offload...")
|
||||
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
|
||||
kwargs = {"torch_dtype": torch_dtype}
|
||||
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||
kwargs["trust_remote_code"] = True
|
||||
_pipe = cls.from_pretrained(model_path, **kwargs)
|
||||
_fix_meta_tensors(_pipe, torch_dtype)
|
||||
_pipe.enable_model_cpu_offload()
|
||||
logger.info(f"Loaded as {name} with CPU offload")
|
||||
@@ -264,7 +490,10 @@ def load_model():
|
||||
# Last resort — sequential offload
|
||||
try:
|
||||
logger.info(f"Reloading {name} with sequential CPU offload...")
|
||||
_pipe = cls.from_pretrained(model_path, torch_dtype=torch_dtype)
|
||||
kwargs = {"torch_dtype": torch_dtype}
|
||||
if name == "DiffusionPipeline" and cls_name_from_index and not hasattr(diffusers, cls_name_from_index):
|
||||
kwargs["trust_remote_code"] = True
|
||||
_pipe = cls.from_pretrained(model_path, **kwargs)
|
||||
_fix_meta_tensors(_pipe, torch_dtype)
|
||||
_pipe.enable_sequential_cpu_offload()
|
||||
logger.info(f"Loaded as {name} with sequential CPU offload")
|
||||
@@ -276,7 +505,7 @@ def load_model():
|
||||
|
||||
return False
|
||||
|
||||
loaded = False
|
||||
if not loaded:
|
||||
for cls, name in candidates:
|
||||
if _load_pipe(cls, name):
|
||||
loaded = True
|
||||
@@ -322,36 +551,19 @@ def load_model():
|
||||
|
||||
if single_file:
|
||||
logger.info(f"Trying from_single_file with: {single_file}")
|
||||
# Detect model family from path/filename to prioritize the right pipeline + config
|
||||
_path_lower = (model_path + "/" + (single_file or "")).lower()
|
||||
_SD35_CONFIGS = ["stabilityai/stable-diffusion-3.5-large", "stabilityai/stable-diffusion-3.5-medium"]
|
||||
_SD3_CONFIGS = ["stabilityai/stable-diffusion-3-medium-diffusers"]
|
||||
_FLUX2_CONFIGS = ["black-forest-labs/FLUX.2-dev"]
|
||||
_FLUX_CONFIGS = ["black-forest-labs/FLUX.1-schnell", "black-forest-labs/FLUX.1-dev"]
|
||||
_SDXL_CONFIGS = ["stabilityai/stable-diffusion-xl-base-1.0"]
|
||||
|
||||
# Build ordered pipeline candidates based on model name hints
|
||||
_pipeline_configs = []
|
||||
if "sd3.5" in _path_lower or "stable-diffusion-3.5" in _path_lower:
|
||||
_pipeline_configs.append(("StableDiffusion3Pipeline", _SD35_CONFIGS))
|
||||
elif "sd3" in _path_lower or "stable-diffusion-3" in _path_lower:
|
||||
_pipeline_configs.append(("StableDiffusion3Pipeline", _SD3_CONFIGS + _SD35_CONFIGS))
|
||||
elif "flux.2" in _path_lower or "flux2" in _path_lower:
|
||||
_pipeline_configs.append(("Flux2Pipeline", _FLUX2_CONFIGS))
|
||||
_pipeline_configs.append(("FluxPipeline", _FLUX_CONFIGS))
|
||||
elif "flux" in _path_lower:
|
||||
_pipeline_configs.append(("FluxPipeline", _FLUX_CONFIGS))
|
||||
_pipeline_configs.append(("Flux2Pipeline", _FLUX2_CONFIGS))
|
||||
elif "sdxl" in _path_lower or "xl" in _path_lower:
|
||||
_pipeline_configs.append(("StableDiffusionXLPipeline", _SDXL_CONFIGS))
|
||||
# Always add all pipelines as fallbacks
|
||||
_pipeline_configs.extend([
|
||||
("Flux2Pipeline", _FLUX2_CONFIGS),
|
||||
("StableDiffusion3Pipeline", _SD35_CONFIGS + _SD3_CONFIGS),
|
||||
("FluxPipeline", _FLUX_CONFIGS),
|
||||
("StableDiffusionXLPipeline", _SDXL_CONFIGS + [None]),
|
||||
("StableDiffusionPipeline", [None]),
|
||||
])
|
||||
explicit_configs = [
|
||||
c.strip()
|
||||
for c in str(_args.single_file_config or "").replace("\n", ",").split(",")
|
||||
if c.strip()
|
||||
]
|
||||
config_candidates = explicit_configs or [None]
|
||||
_pipeline_configs = [
|
||||
("Flux2Pipeline", config_candidates),
|
||||
("StableDiffusion3Pipeline", config_candidates),
|
||||
("FluxPipeline", config_candidates),
|
||||
("StableDiffusionXLPipeline", config_candidates),
|
||||
("StableDiffusionPipeline", config_candidates),
|
||||
]
|
||||
# Deduplicate while preserving order
|
||||
_seen = set()
|
||||
_deduped = []
|
||||
@@ -409,12 +621,12 @@ def load_model():
|
||||
logger.info(f"Trying {cls_name}.from_single_file with config={config}")
|
||||
_pipe = cls.from_single_file(single_file, **kwargs)
|
||||
_fix_meta_tensors(_pipe, torch_dtype)
|
||||
if use_offload:
|
||||
if use_offload and _can_cpu_offload(target_device):
|
||||
_pipe.enable_model_cpu_offload()
|
||||
logger.info(f"Loaded as {cls_name} (single file, config={config}) with CPU offload")
|
||||
else:
|
||||
_pipe = _pipe.to("cuda")
|
||||
logger.info(f"Loaded as {cls_name} (single file, config={config}) on CUDA")
|
||||
_pipe = _pipe.to(target_device)
|
||||
logger.info(f"Loaded as {cls_name} (single file, config={config}) on {target_device}")
|
||||
loaded = True
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -480,17 +692,9 @@ def generate_image(req: ImageRequest):
|
||||
if _pipe is None:
|
||||
return {"error": "Model not loaded"}
|
||||
|
||||
# Parse size
|
||||
try:
|
||||
w, h = req.size.split("x")
|
||||
width, height = int(w), int(h)
|
||||
except Exception:
|
||||
width, height = _args.width, _args.height
|
||||
|
||||
# Map quality to num_inference_steps
|
||||
default_steps = _args.steps or 8
|
||||
steps_map = {"low": 4, "medium": default_steps, "high": 20, "auto": 12}
|
||||
steps = steps_map.get(req.quality, default_steps)
|
||||
width, height = _parse_size(req.size)
|
||||
steps = _quality_steps(req.quality)
|
||||
request_id = _start_progress(req.request_id, steps * max(1, int(req.n or 1)), req.prompt, "generation")
|
||||
|
||||
logger.info(f"Generating: {req.prompt[:80]}... ({width}x{height}, {steps} steps)")
|
||||
start = time.time()
|
||||
@@ -499,44 +703,172 @@ def generate_image(req: ImageRequest):
|
||||
_is_inpaint_pipe = 'inpaint' in type(_pipe).__name__.lower()
|
||||
|
||||
images = []
|
||||
for _ in range(req.n):
|
||||
try:
|
||||
for image_index in range(req.n):
|
||||
progress_offset = image_index * steps
|
||||
negative_prompt = _default_negative_prompt() if _pipeline_accepts_arg("negative_prompt") else None
|
||||
if _is_inpaint_pipe:
|
||||
# Inpaint pipelines need an image + mask — create blank ones for txt2img
|
||||
from PIL import Image as _PILGen
|
||||
_blank = _PILGen.new('RGB', (width, height), (128, 128, 128))
|
||||
_mask = _PILGen.new('L', (width, height), 255) # full white = regenerate everything
|
||||
result = _pipe(
|
||||
prompt=req.prompt,
|
||||
image=_blank,
|
||||
mask_image=_mask,
|
||||
width=width,
|
||||
height=height,
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=3.5,
|
||||
)
|
||||
kwargs = {
|
||||
"prompt": req.prompt,
|
||||
"image": _blank,
|
||||
"mask_image": _mask,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"num_inference_steps": steps,
|
||||
"guidance_scale": _guidance_scale(),
|
||||
}
|
||||
else:
|
||||
result = _pipe(
|
||||
prompt=req.prompt,
|
||||
width=width,
|
||||
height=height,
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=3.5,
|
||||
kwargs = {
|
||||
"prompt": req.prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"num_inference_steps": steps,
|
||||
"guidance_scale": _guidance_scale(),
|
||||
}
|
||||
if negative_prompt:
|
||||
kwargs["negative_prompt"] = negative_prompt
|
||||
result = _run_pipeline_with_progress(
|
||||
_pipe,
|
||||
request_id,
|
||||
steps * max(1, int(req.n or 1)),
|
||||
**kwargs,
|
||||
)
|
||||
_update_progress(request_id, progress_offset + steps, steps * max(1, int(req.n or 1)))
|
||||
img = result.images[0]
|
||||
|
||||
# Convert to base64
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
images.append({"b64_json": b64})
|
||||
images.append(img)
|
||||
except Exception as e:
|
||||
_finish_progress(request_id, "error", str(e))
|
||||
raise
|
||||
|
||||
elapsed = time.time() - start
|
||||
logger.info(f"Generated {req.n} image(s) in {elapsed:.1f}s")
|
||||
_finish_progress(request_id)
|
||||
|
||||
return {
|
||||
"created": int(time.time()),
|
||||
"data": images,
|
||||
}
|
||||
return _image_response(images)
|
||||
|
||||
|
||||
@app.get("/v1/images/progress/{request_id}")
|
||||
def image_progress(request_id: str):
|
||||
item = _PROGRESS.get(request_id)
|
||||
if not item:
|
||||
return {"id": request_id, "status": "unknown", "step": 0, "total": 0, "percent": 0}
|
||||
return item
|
||||
|
||||
|
||||
@app.post("/v1/images/edits")
|
||||
async def edit_image(
|
||||
prompt: str = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
model: str = Form(""),
|
||||
n: int = Form(1),
|
||||
size: str = Form("1024x1024"),
|
||||
quality: str = Form("medium"),
|
||||
response_format: str = Form("b64_json"),
|
||||
request_id: str = Form(""),
|
||||
):
|
||||
if _pipe is None:
|
||||
return {"error": "Model not loaded"}
|
||||
accepts_image = _pipeline_accepts_arg("image")
|
||||
accepts_input_images = _pipeline_accepts_arg("input_images")
|
||||
if not accepts_image and not accepts_input_images:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{type(_pipe).__name__} does not support image edits. Use /v1/images/generations with this model.",
|
||||
)
|
||||
|
||||
from PIL import Image as PILImage, ImageOps
|
||||
|
||||
width, height = _parse_size(size)
|
||||
steps = _quality_steps(quality)
|
||||
request_id = _start_progress(request_id, steps * max(1, min(int(n or 1), 4)), prompt, "edit")
|
||||
raw = await image.read()
|
||||
init_image = PILImage.open(io.BytesIO(raw)).convert("RGB")
|
||||
if width > 0 and height > 0:
|
||||
init_image = ImageOps.fit(init_image, (width, height), method=PILImage.LANCZOS, centering=(0.5, 0.5))
|
||||
|
||||
logger.info(f"Editing image: {prompt[:80]}... ({width}x{height}, {steps} steps)")
|
||||
start = time.time()
|
||||
images = []
|
||||
total_images = max(1, min(int(n or 1), 4))
|
||||
for image_index in range(total_images):
|
||||
progress_offset = image_index * steps
|
||||
try:
|
||||
if accepts_input_images and not accepts_image:
|
||||
negative_prompt = _default_negative_prompt()
|
||||
kwargs = _pipeline_call_kwargs(
|
||||
prompt=prompt,
|
||||
input_images=[init_image],
|
||||
width=width,
|
||||
height=height,
|
||||
num_inference_steps=steps,
|
||||
max_sequence_length=1024,
|
||||
text_guidance_scale=_guidance_scale(),
|
||||
image_guidance_scale=2.0,
|
||||
cfg_range=(0.0, 1.0),
|
||||
negative_prompt=negative_prompt,
|
||||
num_images_per_prompt=1,
|
||||
output_type="pil",
|
||||
max_pixels=width * height if width > 0 and height > 0 else None,
|
||||
max_input_image_side_length=max(width, height) if width > 0 and height > 0 else None,
|
||||
)
|
||||
else:
|
||||
kwargs = _pipeline_call_kwargs(
|
||||
image=init_image,
|
||||
prompt=prompt,
|
||||
width=width,
|
||||
height=height,
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=3.5,
|
||||
true_cfg_scale=4.0,
|
||||
negative_prompt=_default_negative_prompt(),
|
||||
output_type="pil",
|
||||
)
|
||||
result = _run_pipeline_with_progress(
|
||||
_pipe,
|
||||
request_id,
|
||||
steps * total_images,
|
||||
**kwargs,
|
||||
)
|
||||
except TypeError:
|
||||
if accepts_input_images and not accepts_image:
|
||||
kwargs = _pipeline_call_kwargs(
|
||||
prompt=prompt,
|
||||
input_images=[init_image],
|
||||
num_inference_steps=steps,
|
||||
text_guidance_scale=_guidance_scale(),
|
||||
image_guidance_scale=2.0,
|
||||
negative_prompt=_default_negative_prompt(),
|
||||
output_type="pil",
|
||||
)
|
||||
else:
|
||||
kwargs = _pipeline_call_kwargs(
|
||||
image=init_image,
|
||||
prompt=prompt,
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=3.5,
|
||||
negative_prompt=_default_negative_prompt(),
|
||||
output_type="pil",
|
||||
)
|
||||
result = _run_pipeline_with_progress(
|
||||
_pipe,
|
||||
request_id,
|
||||
steps * total_images,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
_finish_progress(request_id, "error", str(e))
|
||||
raise
|
||||
_update_progress(request_id, progress_offset + steps, steps * total_images)
|
||||
images.append(result.images[0])
|
||||
|
||||
elapsed = time.time() - start
|
||||
logger.info(f"Edited {len(images)} image(s) in {elapsed:.1f}s")
|
||||
_finish_progress(request_id)
|
||||
return _image_response(images)
|
||||
|
||||
|
||||
class InpaintRequest(BaseModel):
|
||||
@@ -607,11 +939,12 @@ def _get_inpaint_pipe():
|
||||
]
|
||||
torch_dtype = DTYPE_MAP.get(_args.dtype, torch.bfloat16)
|
||||
harmonize_gpu = _args.harmonize_gpu
|
||||
target_device = _target_device()
|
||||
for name in img2img_names:
|
||||
cls = getattr(diffusers, name, None)
|
||||
if cls:
|
||||
try:
|
||||
if harmonize_gpu is not None:
|
||||
if harmonize_gpu is not None and target_device == "cuda":
|
||||
# Load fresh on separate GPU
|
||||
logger.info(f"Loading {name} on cuda:{harmonize_gpu}...")
|
||||
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
|
||||
@@ -625,10 +958,10 @@ def _get_inpaint_pipe():
|
||||
try:
|
||||
# Some pipelines need from_pretrained instead of from_pipe
|
||||
_img2img_pipe = cls.from_pretrained(_args.model, torch_dtype=torch_dtype)
|
||||
if _args.cpu_offload:
|
||||
if _args.cpu_offload and _can_cpu_offload(target_device):
|
||||
_img2img_pipe.enable_model_cpu_offload()
|
||||
else:
|
||||
_img2img_pipe = _img2img_pipe.to("cuda")
|
||||
_img2img_pipe = _img2img_pipe.to(target_device)
|
||||
logger.info(f"Loaded img2img pipeline (from_pretrained): {name}")
|
||||
return _img2img_pipe, 'img2img'
|
||||
except Exception as e2:
|
||||
@@ -1140,6 +1473,9 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
|
||||
parser.add_argument("--device-map", default=None, help="Device map strategy (unused, kept for compat)")
|
||||
parser.add_argument("--steps", type=int, default=0, help="Default inference steps (0=auto)")
|
||||
parser.add_argument("--guidance-scale", type=float, default=3.5, help="Default classifier-free guidance scale")
|
||||
parser.add_argument("--negative-prompt", default="", help="Default negative prompt for pipelines that support it")
|
||||
parser.add_argument("--single-file-config", default="", help="Base Diffusers repo/path for single-file checkpoints that need missing components. Comma-separated values are tried in order.")
|
||||
parser.add_argument("--width", type=int, default=1024, help="Default output width")
|
||||
parser.add_argument("--height", type=int, default=1024, help="Default output height")
|
||||
parser.add_argument("--cpu-offload", action="store_true", help="Enable model CPU offload")
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenAI-compatible image API wrapper for MLX image models.
|
||||
|
||||
This is intentionally small: it exposes the same `/v1/images/generations`
|
||||
shape Odysseus already uses for local image endpoints, then delegates to the
|
||||
MLX image CLI for the actual generation. Text MLX models still use
|
||||
`mlx_lm.server`; image MLX models should use this wrapper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("mlx_image_server")
|
||||
|
||||
|
||||
class ImageRequest(BaseModel):
|
||||
model: str = ""
|
||||
prompt: str
|
||||
n: int = 1
|
||||
size: str = "1024x1024"
|
||||
quality: str = "medium"
|
||||
response_format: str = "b64_json"
|
||||
|
||||
|
||||
class HarmonizeRequest(BaseModel):
|
||||
image: str
|
||||
prompt: str = ""
|
||||
mask: str | None = None
|
||||
body_mask: str | None = None
|
||||
seam_mask: str | None = None
|
||||
strength: float = 0.35
|
||||
|
||||
|
||||
app = FastAPI(title="Odysseus MLX Image Server")
|
||||
_args: argparse.Namespace
|
||||
|
||||
|
||||
def _steps(quality: str) -> int:
|
||||
if _args.steps:
|
||||
return int(_args.steps)
|
||||
return {"low": 8, "medium": 20, "high": 32, "auto": 20}.get((quality or "medium").lower(), 20)
|
||||
|
||||
|
||||
def _size(size: str) -> tuple[int, int]:
|
||||
try:
|
||||
w, h = str(size or "").lower().split("x", 1)
|
||||
return max(64, int(w)), max(64, int(h))
|
||||
except Exception:
|
||||
return int(_args.width), int(_args.height)
|
||||
|
||||
|
||||
def _cli_for_model(model: str) -> str:
|
||||
lower = model.lower()
|
||||
if "qwen" in lower:
|
||||
return "mflux-generate-qwen"
|
||||
if "flux" in lower:
|
||||
return "mflux-generate"
|
||||
return "mflux-generate"
|
||||
|
||||
|
||||
def _resolve_cli(name: str) -> str:
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
local = Path(sys.executable).resolve().parent / name
|
||||
if local.exists():
|
||||
return str(local)
|
||||
prefix_local = Path(sys.prefix).resolve() / "bin" / name
|
||||
if prefix_local.exists():
|
||||
return str(prefix_local)
|
||||
return ""
|
||||
|
||||
|
||||
def _valid_numbers(values: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for value in values or []:
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
continue
|
||||
try:
|
||||
float(s)
|
||||
except Exception:
|
||||
continue
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _is_hidream(model: str) -> bool:
|
||||
return "hidream" in (model or "").lower()
|
||||
|
||||
|
||||
def _is_boogu(model: str) -> bool:
|
||||
return "boogu" in (model or "").lower()
|
||||
|
||||
|
||||
def _is_lama_inpaint(model: str) -> bool:
|
||||
lower = (model or "").lower()
|
||||
return "mi-gan" in lower or "migan" in lower or "lama" in lower
|
||||
|
||||
|
||||
def _is_ddcolor(model: str) -> bool:
|
||||
return "ddcolor" in (model or "").lower()
|
||||
|
||||
|
||||
def _unsupported_swift_mlx_runtime(model: str) -> HTTPException:
|
||||
if _is_ddcolor(model):
|
||||
return HTTPException(
|
||||
503,
|
||||
"DDColor MLX models require an Odysseus-compatible mlx-ddcolor-swift bridge. "
|
||||
"Build/install a bridge binary named odysseus-mlx-colorize or mlx-ddcolor-serve "
|
||||
"on the Apple Silicon host PATH. Upstream currently ships Swift libraries and "
|
||||
"smoke executables, not a stable colorize CLI.",
|
||||
)
|
||||
return HTTPException(
|
||||
503,
|
||||
"LaMa / MI-GAN MLX inpainting models require an Odysseus-compatible mlx-lama-swift bridge. "
|
||||
"Build/install a bridge binary named odysseus-mlx-inpaint or mlx-lama-serve "
|
||||
"on the Apple Silicon host PATH. Upstream currently ships Swift libraries and "
|
||||
"smoke executables, not a stable image-edit CLI.",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_bridge(names: list[str]) -> str:
|
||||
for name in names:
|
||||
found = _resolve_cli(name)
|
||||
if found:
|
||||
return found
|
||||
return ""
|
||||
|
||||
|
||||
def _snapshot_path(model: str) -> Path:
|
||||
p = Path(model).expanduser()
|
||||
if p.exists():
|
||||
return p
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
503,
|
||||
"huggingface_hub is required to download MLX image model snapshots. "
|
||||
"Install the model requirements in the selected Python environment.",
|
||||
) from e
|
||||
return Path(snapshot_download(model))
|
||||
|
||||
|
||||
def _weights_path(model: str) -> Path:
|
||||
p = Path(model).expanduser()
|
||||
if p.is_file():
|
||||
return p
|
||||
snap = _snapshot_path(model)
|
||||
if snap.is_file():
|
||||
return snap
|
||||
candidates = sorted(snap.rglob("*.safetensors"))
|
||||
if not candidates:
|
||||
raise HTTPException(500, f"No safetensors weights found for {model} in {snap}")
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _write_bridge_input_image(raw: bytes, out_path: Path) -> None:
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
except Exception as e:
|
||||
raise HTTPException(503, "Pillow is required for MLX image edit bridge inputs.") from e
|
||||
try:
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGBA")
|
||||
img.save(out_path, format="PNG")
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid input image: {e}") from e
|
||||
|
||||
|
||||
def _write_bridge_mask(raw: bytes, out_path: Path) -> None:
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
except Exception as e:
|
||||
raise HTTPException(503, "Pillow is required for MLX image edit bridge masks.") from e
|
||||
try:
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
if img.mode == "RGBA":
|
||||
# OpenAI edits mask convention: transparent = regenerate.
|
||||
alpha = img.getchannel("A")
|
||||
mask = alpha.point(lambda p: 255 if p < 128 else 0)
|
||||
else:
|
||||
mask = img.convert("L")
|
||||
mask.save(out_path, format="PNG")
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid mask image: {e}") from e
|
||||
|
||||
|
||||
def _run_bridge(cmd: list[str]) -> None:
|
||||
env = os.environ.copy()
|
||||
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or "MLX Swift bridge failed").strip()
|
||||
logger.error("MLX Swift bridge failed (%s): %s\n%s", proc.returncode, " ".join(cmd), detail[-4000:])
|
||||
raise HTTPException(500, detail[-4000:])
|
||||
|
||||
|
||||
def _run_ddcolor_bridge(model: str, image_raw: bytes, out_path: Path) -> None:
|
||||
bridge = _resolve_bridge(["odysseus-mlx-colorize", "mlx-ddcolor-serve"])
|
||||
if not bridge:
|
||||
raise _unsupported_swift_mlx_runtime(model)
|
||||
with tempfile.TemporaryDirectory(prefix="odysseus-ddcolor-") as td:
|
||||
inp = Path(td) / "input.png"
|
||||
_write_bridge_input_image(image_raw, inp)
|
||||
weights = _weights_path(model)
|
||||
tier = "tiny" if "tiny" in model.lower() else "large"
|
||||
_run_bridge([
|
||||
bridge,
|
||||
"--model", str(weights),
|
||||
"--image", str(inp),
|
||||
"--output", str(out_path),
|
||||
"--tier", tier,
|
||||
])
|
||||
|
||||
|
||||
def _run_inpaint_bridge(model: str, image_raw: bytes, mask_raw: bytes | None, out_path: Path) -> None:
|
||||
if not mask_raw:
|
||||
raise HTTPException(
|
||||
422,
|
||||
"LaMa / MI-GAN inpainting requires an image mask. Use the editor inpaint/object-removal tool so Odysseus can send the mask.",
|
||||
)
|
||||
bridge = _resolve_bridge(["odysseus-mlx-inpaint", "mlx-lama-serve"])
|
||||
if not bridge:
|
||||
raise _unsupported_swift_mlx_runtime(model)
|
||||
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-inpaint-") as td:
|
||||
inp = Path(td) / "input.png"
|
||||
mask = Path(td) / "mask.png"
|
||||
_write_bridge_input_image(image_raw, inp)
|
||||
_write_bridge_mask(mask_raw, mask)
|
||||
weights = _weights_path(model)
|
||||
mode = "fast" if ("mi-gan" in model.lower() or "migan" in model.lower()) else "best"
|
||||
_run_bridge([
|
||||
bridge,
|
||||
"--model", str(weights),
|
||||
"--image", str(inp),
|
||||
"--mask", str(mask),
|
||||
"--output", str(out_path),
|
||||
"--mode", mode,
|
||||
])
|
||||
|
||||
|
||||
def _generate_hidream(model: str, prompt: str, out_path: Path, width: int, height: int, steps: int) -> None:
|
||||
model_path = _snapshot_path(model)
|
||||
script = model_path / "scripts" / "hidream_o1" / "generate_hidream_o1_mlx.py"
|
||||
if not script.exists():
|
||||
raise HTTPException(500, f"HiDream generator script not found in snapshot: {script}")
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--model-path",
|
||||
str(model_path),
|
||||
"--prompt",
|
||||
prompt,
|
||||
"--output",
|
||||
str(out_path),
|
||||
"--width",
|
||||
str(width),
|
||||
"--height",
|
||||
str(height),
|
||||
"--num-inference-steps",
|
||||
str(steps),
|
||||
"--no-snap-resolution",
|
||||
]
|
||||
env = os.environ.copy()
|
||||
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or "HiDream generator failed").strip()
|
||||
raise HTTPException(500, detail[-4000:])
|
||||
|
||||
|
||||
def _generate_boogu(model: str, prompt: str, out_path: Path, width: int, height: int, steps: int) -> None:
|
||||
try:
|
||||
from boogu_image_mlx.pipeline_mlx import BooguImagePipeline
|
||||
from PIL import Image
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
503,
|
||||
"Boogu MLX serving requires boogu-image-mlx in the launch Python. "
|
||||
"Install with: python -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git",
|
||||
) from e
|
||||
|
||||
model_path = _snapshot_path(model)
|
||||
vlm_model = (_args.vlm_model or os.environ.get("ODYSSEUS_MLX_IMAGE_VLM_MODEL") or "").strip()
|
||||
if not vlm_model:
|
||||
raise HTTPException(
|
||||
422,
|
||||
"This MLX image pipeline requires a companion vision-language model. "
|
||||
"Relaunch with --vlm-model <repo_or_path> or set ODYSSEUS_MLX_IMAGE_VLM_MODEL.",
|
||||
)
|
||||
try:
|
||||
pipe = BooguImagePipeline.from_pretrained(
|
||||
str(model_path),
|
||||
vlm_model,
|
||||
)
|
||||
img = pipe.generate(
|
||||
prompt,
|
||||
height=height,
|
||||
width=width,
|
||||
steps=steps,
|
||||
guidance=3.5,
|
||||
)
|
||||
Image.fromarray(img).save(out_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Boogu MLX generation failed: {e}") from e
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
def list_models():
|
||||
return {"data": [{"id": _args.model, "object": "model", "owned_by": "local"}]}
|
||||
|
||||
|
||||
@app.post("/v1/images/generations")
|
||||
def generate(req: ImageRequest):
|
||||
model = req.model or _args.model
|
||||
width, height = _size(req.size)
|
||||
out_images = []
|
||||
count = max(1, min(int(req.n or 1), 4))
|
||||
for _ in range(count):
|
||||
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-image-") as td:
|
||||
out_path = Path(td) / "image.png"
|
||||
if _is_hidream(model):
|
||||
_generate_hidream(model, req.prompt, out_path, width, height, _steps(req.quality))
|
||||
elif _is_boogu(model):
|
||||
_generate_boogu(model, req.prompt, out_path, width, height, _steps(req.quality))
|
||||
elif _is_lama_inpaint(model) or _is_ddcolor(model):
|
||||
raise _unsupported_swift_mlx_runtime(model)
|
||||
else:
|
||||
cli = _cli_for_model(model)
|
||||
cli_path = _resolve_cli(cli)
|
||||
if not cli_path:
|
||||
raise HTTPException(
|
||||
503,
|
||||
f"{cli} not found in PATH or next to {sys.executable}. Install the MLX image runtime with: python3 -m pip install -U mflux",
|
||||
)
|
||||
cmd = [
|
||||
cli_path,
|
||||
"--model",
|
||||
model,
|
||||
"--prompt",
|
||||
req.prompt,
|
||||
"--steps",
|
||||
str(_steps(req.quality)),
|
||||
"--output",
|
||||
str(out_path),
|
||||
]
|
||||
if _args.base_model:
|
||||
cmd += ["--base-model", _args.base_model]
|
||||
if _args.lora_style:
|
||||
cmd += ["--lora-style", _args.lora_style]
|
||||
if _args.lora_paths:
|
||||
cmd += ["--lora-paths", *_args.lora_paths]
|
||||
lora_scales = _valid_numbers(_args.lora_scales)
|
||||
if lora_scales:
|
||||
cmd += ["--lora-scales", *lora_scales]
|
||||
if "qwen" not in model.lower():
|
||||
cmd += ["--width", str(width), "--height", str(height)]
|
||||
env = os.environ.copy()
|
||||
proc = subprocess.run(cmd, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if proc.returncode != 0:
|
||||
detail = (proc.stderr or proc.stdout or f"{cli} failed").strip()
|
||||
logger.error("MLX image command failed (%s): %s\n%s", proc.returncode, " ".join(cmd), detail[-4000:])
|
||||
raise HTTPException(500, detail[-4000:])
|
||||
if not out_path.exists():
|
||||
raise HTTPException(500, f"MLX image generator completed but did not write {out_path}")
|
||||
b64 = base64.b64encode(out_path.read_bytes()).decode("ascii")
|
||||
out_images.append({"b64_json": b64})
|
||||
return {"created": 0, "data": out_images}
|
||||
|
||||
|
||||
@app.post("/v1/images/edits")
|
||||
async def edit_image(
|
||||
image: UploadFile = File(...),
|
||||
mask: UploadFile | None = File(None),
|
||||
prompt: str = Form(""),
|
||||
model: str = Form(""),
|
||||
n: int = Form(1),
|
||||
size: str = Form("1024x1024"),
|
||||
response_format: str = Form("b64_json"),
|
||||
):
|
||||
active_model = model or _args.model
|
||||
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
||||
image_raw = await image.read()
|
||||
mask_raw = await mask.read() if mask is not None else None
|
||||
out_images = []
|
||||
count = max(1, min(int(n or 1), 4))
|
||||
for _ in range(count):
|
||||
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-edit-") as td:
|
||||
out_path = Path(td) / "image.png"
|
||||
if _is_ddcolor(active_model):
|
||||
_run_ddcolor_bridge(active_model, image_raw, out_path)
|
||||
else:
|
||||
_run_inpaint_bridge(active_model, image_raw, mask_raw, out_path)
|
||||
if not out_path.exists():
|
||||
raise HTTPException(500, f"MLX Swift bridge completed but did not write {out_path}")
|
||||
out_images.append({"b64_json": base64.b64encode(out_path.read_bytes()).decode("ascii")})
|
||||
return {"created": 0, "data": out_images}
|
||||
raise HTTPException(
|
||||
422,
|
||||
"This MLX image endpoint supports text-to-image generation only. "
|
||||
"Use /v1/images/generations, or serve an edit/img2img-capable model.",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v1/images/harmonize")
|
||||
def harmonize_image(req: HarmonizeRequest):
|
||||
active_model = _args.model
|
||||
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
|
||||
try:
|
||||
image_raw = base64.b64decode(req.image.split(",", 1)[-1])
|
||||
mask_b64 = req.body_mask or req.mask
|
||||
mask_raw = base64.b64decode(mask_b64.split(",", 1)[-1]) if mask_b64 else None
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Invalid base64 image payload: {e}") from e
|
||||
with tempfile.TemporaryDirectory(prefix="odysseus-mlx-harmonize-") as td:
|
||||
out_path = Path(td) / "image.png"
|
||||
if _is_ddcolor(active_model):
|
||||
_run_ddcolor_bridge(active_model, image_raw, out_path)
|
||||
else:
|
||||
_run_inpaint_bridge(active_model, image_raw, mask_raw, out_path)
|
||||
if not out_path.exists():
|
||||
raise HTTPException(500, f"MLX Swift bridge completed but did not write {out_path}")
|
||||
return {"image": base64.b64encode(out_path.read_bytes()).decode("ascii")}
|
||||
raise HTTPException(
|
||||
422,
|
||||
"This MLX image endpoint supports text-to-image generation only. "
|
||||
"Use /v1/images/generations, or serve an edit/img2img-capable model.",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global _args
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model", required=True)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8100)
|
||||
parser.add_argument("--steps", type=int, default=0)
|
||||
parser.add_argument("--width", type=int, default=1024)
|
||||
parser.add_argument("--height", type=int, default=1024)
|
||||
parser.add_argument("--base-model", default="")
|
||||
parser.add_argument("--lora-style", default="")
|
||||
parser.add_argument("--lora-paths", nargs="*", default=[])
|
||||
parser.add_argument("--lora-scales", nargs="*", default=[])
|
||||
parser.add_argument("--vlm-model", default="")
|
||||
_args = parser.parse_args()
|
||||
uvicorn.run(app, host=_args.host, port=_args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+334
-275
@@ -1,278 +1,328 @@
|
||||
"""Image generation model registry and VRAM fitting for Cookbook."""
|
||||
|
||||
# Curated registry of image generation models supported by diffusers.
|
||||
# ONLY verified HuggingFace repo IDs.
|
||||
# VRAM estimates are for inference (single image generation).
|
||||
IMAGE_MODEL_REGISTRY = [
|
||||
# ── Z-Image (Alibaba Tongyi) ──
|
||||
{
|
||||
"id": "Tongyi-MAI/Z-Image-Turbo",
|
||||
"name": "Z-Image Turbo",
|
||||
"provider": "Tongyi",
|
||||
"params_b": 6.0,
|
||||
"vram_bf16": 19.0,
|
||||
"vram_fp8": 10.0,
|
||||
"vram_q4": 6.0,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {
|
||||
"FP8": "drbaph/Z-Image-Turbo-FP8",
|
||||
},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "6B distilled, 8-step. Sub-second on H800. Apache 2.0.",
|
||||
"quality": 92,
|
||||
"speed": 95,
|
||||
"released": "2025-12",
|
||||
},
|
||||
{
|
||||
"id": "Tongyi-MAI/Z-Image",
|
||||
"name": "Z-Image",
|
||||
"provider": "Tongyi",
|
||||
"params_b": 6.0,
|
||||
"vram_bf16": 19.0,
|
||||
"vram_fp8": 10.0,
|
||||
"vram_q4": 6.0,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {
|
||||
"FP8": "drbaph/Z-Image-fp8",
|
||||
},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Full undistilled model. Highest creative freedom. Apache 2.0.",
|
||||
"quality": 93,
|
||||
"speed": 70,
|
||||
"released": "2025-12",
|
||||
},
|
||||
# ── Qwen Image ──
|
||||
{
|
||||
"id": "Qwen/Qwen-Image-2512",
|
||||
"name": "Qwen Image 2512",
|
||||
"provider": "Qwen",
|
||||
"params_b": 20.0,
|
||||
"vram_bf16": 42.0,
|
||||
"vram_fp8": 22.0,
|
||||
"vram_q4": 14.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image", "text-rendering"],
|
||||
"description": "Dec 2025 update. Better humans, finer detail, strong text. Apache 2.0.",
|
||||
"quality": 95,
|
||||
"speed": 50,
|
||||
"released": "2025-12",
|
||||
},
|
||||
{
|
||||
"id": "Qwen/Qwen-Image",
|
||||
"name": "Qwen Image",
|
||||
"provider": "Qwen",
|
||||
"params_b": 20.0,
|
||||
"vram_bf16": 42.0,
|
||||
"vram_fp8": 22.0,
|
||||
"vram_q4": 14.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image", "text-rendering"],
|
||||
"description": "20B foundation. Best text rendering in images. Apache 2.0.",
|
||||
"quality": 94,
|
||||
"speed": 50,
|
||||
"released": "2025-08",
|
||||
},
|
||||
{
|
||||
"id": "Qwen/Qwen-Image-Edit-2511",
|
||||
"name": "Qwen Image Edit",
|
||||
"provider": "Qwen",
|
||||
"params_b": 20.0,
|
||||
"vram_bf16": 42.0,
|
||||
"vram_fp8": 22.0,
|
||||
"vram_q4": 14.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["image-editing", "inpainting"],
|
||||
"description": "Dedicated editing. Style transfer, object removal. Apache 2.0.",
|
||||
"quality": 92,
|
||||
"speed": 50,
|
||||
"released": "2025-11",
|
||||
},
|
||||
# ── Stable Diffusion (dedicated inpainting) ──
|
||||
{
|
||||
"id": "diffusers/stable-diffusion-xl-1.0-inpainting-0.1",
|
||||
"name": "SDXL Inpainting",
|
||||
"provider": "Stability AI",
|
||||
"params_b": 3.5,
|
||||
"vram_bf16": 12.0,
|
||||
"vram_fp8": 8.0,
|
||||
"vram_q4": 6.0,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["inpainting", "image-editing"],
|
||||
"description": "SDXL fine-tuned for inpainting (9-channel UNet). Best SD-family fill quality; fits a 24GB card comfortably.",
|
||||
"quality": 86,
|
||||
"speed": 68,
|
||||
"released": "2023-11",
|
||||
},
|
||||
{
|
||||
"id": "stable-diffusion-v1-5/stable-diffusion-inpainting",
|
||||
"name": "SD 1.5 Inpainting",
|
||||
"provider": "Stability AI",
|
||||
"params_b": 1.1,
|
||||
"vram_bf16": 4.0,
|
||||
"vram_fp8": 3.0,
|
||||
"vram_q4": 2.5,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["inpainting"],
|
||||
"description": "Classic SD 1.5 inpaint. Very light and fast; lower fidelity than SDXL.",
|
||||
"quality": 70,
|
||||
"speed": 92,
|
||||
"released": "2022-10",
|
||||
},
|
||||
# ── FLUX ──
|
||||
{
|
||||
"id": "black-forest-labs/FLUX.1-dev",
|
||||
"name": "FLUX.1 Dev",
|
||||
"provider": "Black Forest Labs",
|
||||
"params_b": 12.0,
|
||||
"vram_bf16": 33.0,
|
||||
"vram_fp8": 17.0,
|
||||
"vram_q4": 10.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {
|
||||
"FP8": "diffusers/FLUX.1-dev-torchao-fp8",
|
||||
},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "High quality, detailed. Popular community model. Non-commercial.",
|
||||
"quality": 92,
|
||||
"speed": 55,
|
||||
"released": "2024-08",
|
||||
},
|
||||
{
|
||||
"id": "black-forest-labs/FLUX.1-schnell",
|
||||
"name": "FLUX.1 Schnell",
|
||||
"provider": "Black Forest Labs",
|
||||
"params_b": 12.0,
|
||||
"vram_bf16": 33.0,
|
||||
"vram_fp8": 17.0,
|
||||
"vram_q4": 10.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {
|
||||
"FP8": "Kijai/flux-fp8",
|
||||
},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Fast 4-step variant. Apache 2.0 license.",
|
||||
"quality": 85,
|
||||
"speed": 90,
|
||||
"released": "2024-08",
|
||||
},
|
||||
# ── Stable Diffusion ──
|
||||
{
|
||||
"id": "stabilityai/stable-diffusion-3.5-medium",
|
||||
"name": "SD 3.5 Medium",
|
||||
"provider": "Stability AI",
|
||||
"params_b": 2.5,
|
||||
"vram_bf16": 12.0,
|
||||
"vram_fp8": 7.0,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {
|
||||
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
|
||||
},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "2.5B lightweight, fast. Fits almost any GPU.",
|
||||
"quality": 75,
|
||||
"speed": 95,
|
||||
"released": "2024-10",
|
||||
},
|
||||
{
|
||||
"id": "stabilityai/stable-diffusion-3.5-large",
|
||||
"name": "SD 3.5 Large",
|
||||
"provider": "Stability AI",
|
||||
"params_b": 8.1,
|
||||
"vram_bf16": 22.0,
|
||||
"vram_fp8": 12.0,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {
|
||||
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
|
||||
},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "8B high quality. Good balance of speed and quality.",
|
||||
"quality": 85,
|
||||
"speed": 70,
|
||||
"released": "2024-10",
|
||||
},
|
||||
{
|
||||
"id": "stabilityai/stable-diffusion-3.5-large-turbo",
|
||||
"name": "SD 3.5 Large Turbo",
|
||||
"provider": "Stability AI",
|
||||
"params_b": 8.1,
|
||||
"vram_bf16": 22.0,
|
||||
"vram_fp8": 12.0,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {
|
||||
"FP8": "Comfy-Org/stable-diffusion-3.5-fp8",
|
||||
},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Distilled for few-step inference. Fastest large SD.",
|
||||
"quality": 80,
|
||||
"speed": 92,
|
||||
"released": "2024-10",
|
||||
},
|
||||
{
|
||||
"id": "stabilityai/stable-diffusion-xl-base-1.0",
|
||||
"name": "SDXL",
|
||||
"provider": "Stability AI",
|
||||
"params_b": 3.5,
|
||||
"vram_bf16": 10.0,
|
||||
"vram_fp8": 6.0,
|
||||
"vram_q4": None,
|
||||
"default_quant": "BF16",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image"],
|
||||
"description": "Classic workhorse. Huge LoRA ecosystem. Fits 8GB+.",
|
||||
"quality": 72,
|
||||
"speed": 90,
|
||||
"released": "2023-07",
|
||||
},
|
||||
# ── Hunyuan ──
|
||||
{
|
||||
"id": "tencent/HunyuanImage-3.0",
|
||||
"name": "HunyuanImage 3.0",
|
||||
"provider": "Tencent",
|
||||
"params_b": 13.0,
|
||||
"vram_bf16": 30.0,
|
||||
"vram_fp8": 16.0,
|
||||
"vram_q4": 9.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {
|
||||
"Q4": "wikeeyang/Hunyuan-Image-30-Qint4",
|
||||
"NF4": "EricRollei/HunyuanImage-3.0-Instruct-NF4",
|
||||
},
|
||||
"capabilities": ["text-to-image", "text-rendering"],
|
||||
"description": "Strong text rendering. Bilingual Chinese/English. 13B activated per token.",
|
||||
"quality": 88,
|
||||
"speed": 60,
|
||||
"released": "2025-09",
|
||||
},
|
||||
{
|
||||
"id": "tencent/HunyuanImage-3.0-Instruct-Distil",
|
||||
"name": "HunyuanImage 3.0 Distil",
|
||||
"provider": "Tencent",
|
||||
"params_b": 13.0,
|
||||
"vram_bf16": 30.0,
|
||||
"vram_fp8": 16.0,
|
||||
"vram_q4": 9.0,
|
||||
"default_quant": "FP8",
|
||||
"quant_repos": {},
|
||||
"capabilities": ["text-to-image", "text-rendering"],
|
||||
"description": "Distilled variant, fewer steps. Faster with comparable quality.",
|
||||
"quality": 85,
|
||||
"speed": 80,
|
||||
"released": "2026-01",
|
||||
},
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
# Image models are discovered from HuggingFace collections/search and local cache.
|
||||
# Keep this empty: source-coded repo IDs become hidden recommendations.
|
||||
IMAGE_MODEL_REGISTRY: list[dict[str, Any]] = []
|
||||
|
||||
HF_IMAGE_COLLECTIONS = [
|
||||
"stabilityai/image",
|
||||
"stabilityai/stable-diffusion-35",
|
||||
"black-forest-labs/flux2",
|
||||
]
|
||||
|
||||
HF_MLX_IMAGE_COLLECTIONS = [
|
||||
"mlx-community/flux2-klein-mlx",
|
||||
"mlx-community/inpainting-mlx",
|
||||
"mlx-community/ddcolor-mlx",
|
||||
"mlx-community/boogu-image-01-mlx",
|
||||
]
|
||||
|
||||
HF_MLX_IMAGE_REPO_SEEDS: list[str] = []
|
||||
HF_IMAGE_REPO_SEEDS: list[str] = []
|
||||
|
||||
_HF_COLLECTION_CACHE = {"ts": 0.0, "models": []}
|
||||
_HF_COLLECTION_TTL = 30 * 60
|
||||
_HF_VARIANT_CACHE: dict[str, dict[str, str]] = {}
|
||||
_HF_SEARCH_DISABLED_UNTIL = 0.0
|
||||
|
||||
|
||||
def _repo_display_name(repo_id: str) -> str:
|
||||
name = str(repo_id or "").split("/")[-1]
|
||||
return name.replace("-", " ").replace("_", " ").strip() or repo_id
|
||||
|
||||
|
||||
def _provider_from_repo(repo_id: str) -> str:
|
||||
owner = str(repo_id or "").split("/", 1)[0].lower()
|
||||
return {
|
||||
"stabilityai": "Stability AI",
|
||||
"black-forest-labs": "Black Forest Labs",
|
||||
"tongyi-mai": "Tongyi",
|
||||
"qwen": "Qwen",
|
||||
"mlx-community": "mlx-community",
|
||||
}.get(owner, owner.replace("-", " ").title() if owner else "HuggingFace")
|
||||
|
||||
|
||||
def _infer_capabilities(item: dict[str, Any], repo_id: str) -> list[str]:
|
||||
tasks = set()
|
||||
pipeline = str(item.get("pipeline_tag") or "").strip().lower()
|
||||
if pipeline:
|
||||
tasks.add(pipeline)
|
||||
for provider in item.get("availableInferenceProviders") or []:
|
||||
if isinstance(provider, dict) and provider.get("task"):
|
||||
tasks.add(str(provider["task"]).strip().lower())
|
||||
text = f"{repo_id} {' '.join(tasks)}".lower()
|
||||
caps = []
|
||||
if "image-to-image" in tasks or "edit" in text or "inpaint" in text:
|
||||
caps.append("image-editing")
|
||||
if "inpaint" in text:
|
||||
caps.append("inpainting")
|
||||
if "text-to-image" in tasks or not caps:
|
||||
caps.append("text-to-image")
|
||||
return caps
|
||||
|
||||
|
||||
def _estimate_image_model(repo_id: str) -> dict[str, Any]:
|
||||
text = str(repo_id or "").lower()
|
||||
params_b = 8.0
|
||||
param_match = re.search(r"(?<![\d.])(\d+(?:\.\d+)?)\s*b(?:\b|[-_])", text)
|
||||
if param_match:
|
||||
params_b = max(0.01, float(param_match.group(1)))
|
||||
if any(k in text for k in ("mi-gan", "big-lama", "lama-")):
|
||||
return {"params_b": 0.01, "bf16": 1.0, "fp8": 0.7, "q4": 0.5, "quality": 65, "speed": 98, "quant": "BF16"}
|
||||
quant = "BF16"
|
||||
if any(k in text for k in ("4bit", "q4", "nf4")):
|
||||
quant = "Q4"
|
||||
elif "fp8" in text or "8bit" in text:
|
||||
quant = "FP8"
|
||||
bf16 = max(1.0, round(params_b * 2.6 + 3.0, 1))
|
||||
fp8 = max(0.7, round(params_b * 1.35 + 2.0, 1))
|
||||
q4 = max(0.5, round(params_b * 0.8 + 1.5, 1))
|
||||
speed = max(35, min(95, int(98 - params_b * 3)))
|
||||
quality = max(60, min(88, int(70 + min(params_b, 18) * 0.8)))
|
||||
return {"params_b": params_b, "bf16": bf16, "fp8": fp8, "q4": q4, "quality": quality, "speed": speed, "quant": quant}
|
||||
|
||||
|
||||
def _params_b_from_item(item: dict[str, Any]) -> float | None:
|
||||
raw = item.get("numParameters")
|
||||
if isinstance(raw, (int, float)) and raw > 0:
|
||||
return max(0.01, round(float(raw) / 1_000_000_000.0, 3))
|
||||
return None
|
||||
|
||||
|
||||
def _mlx_quantize_estimate(repo_id: str, est: dict[str, Any]) -> dict[str, Any]:
|
||||
text = str(repo_id or "").lower()
|
||||
out = dict(est)
|
||||
if "3bit" in text or "4bit" in text or "q4" in text:
|
||||
out["quant"] = "Q4"
|
||||
out["bf16"] = None
|
||||
out["fp8"] = None
|
||||
elif "8bit" in text:
|
||||
out["quant"] = "FP8"
|
||||
out["bf16"] = None
|
||||
elif "6bit" in text or "5bit" in text:
|
||||
out["quant"] = "Q4"
|
||||
out["bf16"] = None
|
||||
out["fp8"] = out.get("fp8") or out.get("q4")
|
||||
elif "bf16" in text or "fp16" in text:
|
||||
out["quant"] = "BF16"
|
||||
out["fp8"] = None
|
||||
out["q4"] = None
|
||||
return out
|
||||
|
||||
|
||||
def _collection_item_to_model(item: dict[str, Any], collection_title: str = "", mlx_only: bool = False) -> dict[str, Any] | None:
|
||||
repo_id = str(item.get("id") or "").strip()
|
||||
if "/" not in repo_id:
|
||||
return None
|
||||
typ = str(item.get("type") or item.get("itemType") or "model").lower()
|
||||
if typ not in {"", "model"}:
|
||||
return None
|
||||
est = _estimate_image_model(repo_id)
|
||||
item_params_b = _params_b_from_item(item)
|
||||
if item_params_b is not None:
|
||||
est = {
|
||||
**est,
|
||||
"params_b": item_params_b,
|
||||
"bf16": max(0.5, round(item_params_b * 2.4 + 0.8, 1)),
|
||||
"fp8": max(0.5, round(item_params_b * 1.3 + 0.5, 1)),
|
||||
"q4": max(0.4, round(item_params_b * 0.8 + 0.4, 1)),
|
||||
}
|
||||
if mlx_only:
|
||||
est = _mlx_quantize_estimate(repo_id, est)
|
||||
caps = _infer_capabilities(item, repo_id)
|
||||
gated = item.get("gated")
|
||||
desc_bits = []
|
||||
if collection_title:
|
||||
desc_bits.append(f"HF collection: {collection_title}.")
|
||||
if gated:
|
||||
desc_bits.append("Gated on HuggingFace.")
|
||||
out = {
|
||||
"id": repo_id,
|
||||
"name": _repo_display_name(repo_id),
|
||||
"provider": _provider_from_repo(repo_id),
|
||||
"params_b": est["params_b"],
|
||||
"vram_bf16": est["bf16"],
|
||||
"vram_fp8": est["fp8"],
|
||||
"vram_q4": est["q4"],
|
||||
"default_quant": est["quant"],
|
||||
"quant_repos": {},
|
||||
"capabilities": caps,
|
||||
"description": " ".join(desc_bits).strip() or "Imported from HuggingFace collection.",
|
||||
"quality": est["quality"],
|
||||
"speed": est["speed"],
|
||||
"released": "",
|
||||
}
|
||||
if mlx_only:
|
||||
out["mlx_only"] = True
|
||||
out["description"] = (out["description"] + " Apple Silicon / MLX only.").strip()
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_hf_image_collection_models() -> list[dict[str, Any]]:
|
||||
now = time.time()
|
||||
if now - float(_HF_COLLECTION_CACHE.get("ts") or 0) < _HF_COLLECTION_TTL:
|
||||
return list(_HF_COLLECTION_CACHE.get("models") or [])
|
||||
models: list[dict[str, Any]] = []
|
||||
for slug, mlx_only in [(slug, False) for slug in HF_IMAGE_COLLECTIONS] + [(slug, True) for slug in HF_MLX_IMAGE_COLLECTIONS]:
|
||||
url = f"https://huggingface.co/api/collections/{slug}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Odysseus-Cookbook/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
continue
|
||||
title = str(data.get("title") or slug)
|
||||
for item in data.get("items") or []:
|
||||
if isinstance(item, dict):
|
||||
model = _collection_item_to_model(item, title, mlx_only=mlx_only)
|
||||
if model:
|
||||
models.append(model)
|
||||
_HF_COLLECTION_CACHE["ts"] = now
|
||||
_HF_COLLECTION_CACHE["models"] = models
|
||||
return list(models)
|
||||
|
||||
|
||||
def _hf_model_search(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
global _HF_SEARCH_DISABLED_UNTIL
|
||||
now = time.time()
|
||||
if now < _HF_SEARCH_DISABLED_UNTIL:
|
||||
return []
|
||||
url = "https://huggingface.co/api/models?" + urllib.parse.urlencode({
|
||||
"search": query,
|
||||
"limit": str(limit),
|
||||
})
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Odysseus-Cookbook/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=2.5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8", "replace"))
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
_HF_SEARCH_DISABLED_UNTIL = now + 10 * 60
|
||||
return []
|
||||
|
||||
|
||||
def _variant_score(candidate: dict[str, Any], base_repo: str, want: str) -> float:
|
||||
rid = str(candidate.get("id") or candidate.get("modelId") or "")
|
||||
text = " ".join([
|
||||
rid,
|
||||
str(candidate.get("library_name") or ""),
|
||||
str(candidate.get("pipeline_tag") or ""),
|
||||
" ".join(str(t) for t in candidate.get("tags") or []),
|
||||
]).lower()
|
||||
base = base_repo.lower()
|
||||
base_short = base_repo.rsplit("/", 1)[-1].lower()
|
||||
if want == "gguf" and "gguf" not in text:
|
||||
return -1
|
||||
if want == "fp8" and not any(k in text for k in ("fp8", "nvfp4", "mxfp8", "mxfp4")):
|
||||
return -1
|
||||
score = float(candidate.get("downloads") or 0) / 1000.0 + float(candidate.get("likes") or 0)
|
||||
if f"base_model:{base}" in text or f"base_model:quantized:{base}" in text:
|
||||
score += 10000
|
||||
elif base_short and base_short in rid.lower():
|
||||
score += 1000
|
||||
else:
|
||||
score -= 200
|
||||
if "diffusers" in text:
|
||||
score += 50
|
||||
if str(candidate.get("private")).lower() == "true":
|
||||
score -= 10000
|
||||
return score
|
||||
|
||||
|
||||
def _best_variant_repo(base_repo: str, want: str) -> str:
|
||||
base_short = str(base_repo or "").rsplit("/", 1)[-1]
|
||||
candidates = _hf_model_search(f"{base_short} {want}", limit=12)
|
||||
scored = []
|
||||
for item in candidates:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rid = str(item.get("id") or item.get("modelId") or "").strip()
|
||||
if "/" not in rid or rid.lower() == base_repo.lower():
|
||||
continue
|
||||
score = _variant_score(item, base_repo, want)
|
||||
if score >= 0:
|
||||
scored.append((score, rid))
|
||||
scored.sort(reverse=True)
|
||||
return scored[0][1] if scored else ""
|
||||
|
||||
|
||||
def _should_discover_variants(repo_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _discover_quant_repos(repo_id: str, need_fp8: bool = True, need_gguf: bool = True) -> dict[str, str]:
|
||||
key = str(repo_id or "").strip()
|
||||
if not key:
|
||||
return {}
|
||||
cache_key = f"{key.lower()}|fp8={int(need_fp8)}|gguf={int(need_gguf)}"
|
||||
if cache_key in _HF_VARIANT_CACHE:
|
||||
return dict(_HF_VARIANT_CACHE[cache_key])
|
||||
found: dict[str, str] = {}
|
||||
if need_fp8:
|
||||
fp8 = _best_variant_repo(key, "fp8")
|
||||
if fp8:
|
||||
found["FP8"] = fp8
|
||||
if need_gguf:
|
||||
gguf = _best_variant_repo(key, "gguf")
|
||||
if gguf:
|
||||
# The image-model fitter's smallest bucket is Q4; most HF image GGUF
|
||||
# repos expose Q4/Q5/Q8 files under one repo, so use it as the low-VRAM
|
||||
# download source while preserving the explicit GGUF label for callers.
|
||||
found["Q4"] = gguf
|
||||
found["GGUF"] = gguf
|
||||
_HF_VARIANT_CACHE[cache_key] = found
|
||||
return dict(found)
|
||||
|
||||
|
||||
def _merge_quant_repos(model: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(model)
|
||||
existing = dict(out.get("quant_repos") or {})
|
||||
repo_id = str(out.get("id") or "")
|
||||
if _should_discover_variants(repo_id):
|
||||
discovered = _discover_quant_repos(
|
||||
repo_id,
|
||||
need_fp8="FP8" not in existing,
|
||||
need_gguf="Q4" not in existing and "GGUF" not in existing,
|
||||
)
|
||||
for k, v in discovered.items():
|
||||
existing.setdefault(k, v)
|
||||
out["quant_repos"] = existing
|
||||
return out
|
||||
|
||||
|
||||
def get_image_models():
|
||||
"""Return the image model registry."""
|
||||
return IMAGE_MODEL_REGISTRY
|
||||
merged = [_merge_quant_repos(m) for m in IMAGE_MODEL_REGISTRY]
|
||||
seen = {str(m.get("id") or "").lower() for m in merged if isinstance(m, dict)}
|
||||
for model in _fetch_hf_image_collection_models():
|
||||
key = str(model.get("id") or "").lower()
|
||||
if key and key not in seen:
|
||||
merged.append(_merge_quant_repos(model))
|
||||
seen.add(key)
|
||||
return merged
|
||||
|
||||
|
||||
def _is_apple_image_system(system: dict[str, Any]) -> bool:
|
||||
backend = str(system.get("backend") or "").lower()
|
||||
gpu_name = str(system.get("gpu_name") or "").lower()
|
||||
cpu_name = str(system.get("cpu_name") or "").lower()
|
||||
platform = str(system.get("platform") or "").lower()
|
||||
return (
|
||||
bool(system.get("unified_memory"))
|
||||
or backend in {"metal", "mps", "apple"}
|
||||
or "apple" in gpu_name
|
||||
or "apple" in cpu_name
|
||||
or platform == "darwin"
|
||||
)
|
||||
|
||||
|
||||
def rank_image_models(system, search=None, sort="fit"):
|
||||
@@ -284,9 +334,17 @@ def rank_image_models(system, search=None, sort="fit"):
|
||||
system = {}
|
||||
gpu_vram = system.get("gpu_vram_gb", 0) or 0
|
||||
has_gpu = system.get("has_gpu", False)
|
||||
ram_gb = system.get("available_ram_gb") or system.get("total_ram_gb") or 0
|
||||
budget_gb = gpu_vram if has_gpu and gpu_vram > 0 else ram_gb
|
||||
budget_kind = "gpu" if has_gpu and gpu_vram > 0 else "ram"
|
||||
apple_system = _is_apple_image_system(system)
|
||||
results = []
|
||||
|
||||
for model in IMAGE_MODEL_REGISTRY:
|
||||
for model in get_image_models():
|
||||
if apple_system and not (model.get("mlx_only") or model.get("apple_ok")):
|
||||
continue
|
||||
if model.get("mlx_only") and not apple_system:
|
||||
continue
|
||||
# Filter by search
|
||||
if isinstance(search, str) and search:
|
||||
s = search.lower()
|
||||
@@ -299,11 +357,11 @@ def rank_image_models(system, search=None, sort="fit"):
|
||||
fits = False
|
||||
quant_repo = None
|
||||
|
||||
if has_gpu and gpu_vram > 0:
|
||||
if budget_gb > 0:
|
||||
# Try BF16 first, then FP8, then Q4
|
||||
for q, vram_key in [("BF16", "vram_bf16"), ("FP8", "vram_fp8"), ("Q4", "vram_q4")]:
|
||||
v = model.get(vram_key)
|
||||
if v is not None and v <= gpu_vram * 0.90: # 10% headroom
|
||||
if v is not None and v <= budget_gb * 0.90: # 10% headroom
|
||||
quant = q
|
||||
vram_needed = v
|
||||
fits = True
|
||||
@@ -315,15 +373,15 @@ def rank_image_models(system, search=None, sort="fit"):
|
||||
vram_needed = model.get("vram_bf16", 0)
|
||||
|
||||
# Fit label
|
||||
if not has_gpu:
|
||||
if budget_gb <= 0:
|
||||
fit = "no_gpu"
|
||||
fit_label = "No GPU"
|
||||
elif fits:
|
||||
headroom = gpu_vram - vram_needed
|
||||
if headroom > gpu_vram * 0.3:
|
||||
headroom = budget_gb - vram_needed
|
||||
if headroom > budget_gb * 0.3:
|
||||
fit = "perfect"
|
||||
fit_label = "Perfect"
|
||||
elif headroom > gpu_vram * 0.1:
|
||||
elif headroom > budget_gb * 0.1:
|
||||
fit = "good"
|
||||
fit_label = "Good"
|
||||
else:
|
||||
@@ -355,6 +413,7 @@ def rank_image_models(system, search=None, sort="fit"):
|
||||
"fits": fits,
|
||||
"fit": fit,
|
||||
"fit_label": fit_label,
|
||||
"fit_budget": budget_kind,
|
||||
"quality": model["quality"],
|
||||
"speed": model["speed"],
|
||||
"score": round(score, 1),
|
||||
|
||||
@@ -110,6 +110,18 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||
|
||||
# Workspace / coding-agent intent. These should promote to the agent
|
||||
# workspace with shell/file tools available, not the "light" typed-tool
|
||||
# path used for notes/calendar/email.
|
||||
("workspace", "repo implementation request", rf"{_PLEASE}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
|
||||
("workspace", "assistant repo implementation request", rf"{_ACTION_QUESTION}(?:fix|debug|implement|change|update|refactor|patch|review|test)\b.{{0,160}}\b(?:repo|repository|codebase|project|app|server|api|frontend|backend|tests?|bug|issue|pr)\b"),
|
||||
("workspace", "test/build command request", rf"{_PLEASE}(?:run|execute|start|launch)\b.{{0,80}}\b(?:tests?|pytest|npm\s+test|pnpm\s+test|yarn\s+test|build|lint|typecheck|benchmark|eval|terminal[- ]bench|tbench)\b"),
|
||||
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
|
||||
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
|
||||
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
|
||||
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
|
||||
("workspace", "terminal workspace request", r"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|benchmark|terminal[- ]bench|tbench)\b"),
|
||||
|
||||
# Shell / remote-host intent.
|
||||
("shell", "ssh request", r"\bssh\s+(?:in)?to\b"),
|
||||
("shell", "ssh target request", r"\bssh\s+\w+"),
|
||||
|
||||
+765
-46
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from .subprocess_tools import BashTool, PythonTool
|
||||
from .web_tools import WebSearchTool, WebFetchTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, ApplyPatchTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||
from .coding_tools import TodoWriteTool
|
||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||
from .interaction_tools import AskUserTool, UpdatePlanTool
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
@@ -41,6 +42,8 @@ TOOL_HANDLERS = {
|
||||
"read_file": ReadFileTool().execute,
|
||||
"write_file": WriteFileTool().execute,
|
||||
"edit_file": EditFileTool().execute,
|
||||
"apply_patch": ApplyPatchTool().execute,
|
||||
"todowrite": TodoWriteTool().execute,
|
||||
"ls": LsTool().execute,
|
||||
"glob": GlobTool().execute,
|
||||
"grep": GrepTool().execute,
|
||||
@@ -74,6 +77,7 @@ PYTHON_TIMEOUT = 30
|
||||
|
||||
# Tool types that trigger execution
|
||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
||||
"apply_patch", "todowrite",
|
||||
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
|
||||
"create_document", "update_document", "edit_document",
|
||||
"search_chats",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
|
||||
_TODO_DIR = os.path.join(DATA_DIR, "agent_todos")
|
||||
|
||||
|
||||
def _safe_session_id(value: str) -> str:
|
||||
value = value or "current"
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)[:120] or "current"
|
||||
|
||||
|
||||
class TodoWriteTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
try:
|
||||
args = json.loads(content) if (content or "").strip().startswith("{") else {"todos": []}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {"error": "todowrite: JSON object required", "exit_code": 1}
|
||||
todos = args.get("todos")
|
||||
if not isinstance(todos, list):
|
||||
return {"error": "todowrite: todos must be a list", "exit_code": 1}
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
allowed_statuses = {"pending", "in_progress", "completed"}
|
||||
allowed_priorities = {"low", "medium", "high"}
|
||||
active_count = 0
|
||||
for item in todos:
|
||||
if not isinstance(item, dict):
|
||||
return {"error": "todowrite: each todo must be an object", "exit_code": 1}
|
||||
content_text = str(item.get("content") or item.get("text") or "").strip()
|
||||
if not content_text:
|
||||
return {"error": "todowrite: todo content required", "exit_code": 1}
|
||||
status = str(item.get("status") or "pending").strip()
|
||||
if status not in allowed_statuses:
|
||||
return {"error": f"todowrite: invalid status {status!r}", "exit_code": 1}
|
||||
if status == "in_progress":
|
||||
active_count += 1
|
||||
priority = str(item.get("priority") or "medium").strip()
|
||||
if priority not in allowed_priorities:
|
||||
priority = "medium"
|
||||
normalized.append({
|
||||
"content": content_text,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
})
|
||||
if active_count > 1:
|
||||
return {"error": "todowrite: only one todo can be in_progress", "exit_code": 1}
|
||||
|
||||
session_id = _safe_session_id(str(ctx.get("session_id") or args.get("session_id") or "current"))
|
||||
os.makedirs(_TODO_DIR, exist_ok=True)
|
||||
path = os.path.join(_TODO_DIR, f"{session_id}.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({"todos": normalized}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
lines = []
|
||||
for item in normalized:
|
||||
marker = {"pending": " ", "in_progress": ">", "completed": "x"}[item["status"]]
|
||||
lines.append(f"[{marker}] {item['content']} ({item['priority']})")
|
||||
return {
|
||||
"output": "Updated todo list:\n" + ("\n".join(lines) if lines else "(empty)"),
|
||||
"exit_code": 0,
|
||||
"todos": normalized,
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
import difflib
|
||||
import fnmatch
|
||||
import shutil
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
|
||||
from src.constants import MAX_READ_CHARS, MAX_DIFF_LINES, MAX_OUTPUT_CHARS
|
||||
|
||||
@@ -230,6 +230,181 @@ class WriteFileTool:
|
||||
result["diff"] = diff
|
||||
return result
|
||||
|
||||
class ApplyPatchTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
"""Apply a small Codex-style patch using exact context matching.
|
||||
|
||||
This is deliberately stricter than git-apply: if an update hunk's old
|
||||
text is not found exactly once, the whole patch is rejected before any
|
||||
file is changed. That keeps agent edits reviewable and avoids fuzzy
|
||||
corruption when the model patches stale context.
|
||||
"""
|
||||
from src.tool_execution import _resolve_tool_path
|
||||
|
||||
patch_text = content or ""
|
||||
stripped = patch_text.strip()
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
args = json.loads(stripped)
|
||||
if isinstance(args, dict):
|
||||
patch_text = str(args.get("patch_text") or args.get("patchText") or args.get("patch") or "")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if not patch_text.strip():
|
||||
return {"error": "apply_patch: patch_text required", "exit_code": 1}
|
||||
|
||||
try:
|
||||
ops = _parse_agent_patch(patch_text)
|
||||
if not ops:
|
||||
return {"error": "apply_patch: no file operations found", "exit_code": 1}
|
||||
prepared = []
|
||||
for op in ops:
|
||||
path = _resolve_tool_path(op["path"])
|
||||
kind = op["kind"]
|
||||
if kind == "add":
|
||||
if os.path.exists(path):
|
||||
return {"error": f"apply_patch: {op['path']}: already exists", "exit_code": 1}
|
||||
old = ""
|
||||
new = op["content"]
|
||||
elif kind == "delete":
|
||||
if not os.path.isfile(path):
|
||||
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
old = f.read()
|
||||
new = ""
|
||||
else:
|
||||
if not os.path.isfile(path):
|
||||
return {"error": f"apply_patch: {op['path']}: not found", "exit_code": 1}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
old = f.read()
|
||||
new = _apply_patch_hunks(old, op["hunks"], op["path"])
|
||||
prepared.append((kind, path, old, new))
|
||||
|
||||
diffs = []
|
||||
for kind, path, old, new in prepared:
|
||||
if kind == "delete":
|
||||
os.remove(path)
|
||||
else:
|
||||
directory = os.path.dirname(path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(new)
|
||||
diff = _unified_diff(old, new, path)
|
||||
if diff:
|
||||
diffs.append(diff)
|
||||
except (ValueError, UnicodeDecodeError, PermissionError, OSError) as e:
|
||||
return {"error": f"apply_patch: {e}", "exit_code": 1}
|
||||
|
||||
added = sum(int(d.get("added") or 0) for d in diffs)
|
||||
removed = sum(int(d.get("removed") or 0) for d in diffs)
|
||||
text_parts = [d.get("text", "") for d in diffs if d.get("text")]
|
||||
diff_text = "\n".join(text_parts)
|
||||
if len(diff_text.splitlines()) > MAX_DIFF_LINES:
|
||||
diff_text = "\n".join(diff_text.splitlines()[:MAX_DIFF_LINES]) + f"\n... diff truncated at {MAX_DIFF_LINES} lines"
|
||||
result = {
|
||||
"output": f"Applied patch ({len(prepared)} file{'s' if len(prepared) != 1 else ''}, +{added}/-{removed})",
|
||||
"exit_code": 0,
|
||||
}
|
||||
if diffs:
|
||||
result["diff"] = {
|
||||
"text": diff_text,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"new_file": any(d.get("new_file") for d in diffs),
|
||||
"file": "patch",
|
||||
}
|
||||
return result
|
||||
|
||||
def _parse_agent_patch(patch_text: str) -> List[Dict[str, Any]]:
|
||||
lines = patch_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
if not lines or lines[0].strip() != "*** Begin Patch":
|
||||
raise ValueError("patch must start with *** Begin Patch")
|
||||
if lines[-1].strip() != "*** End Patch":
|
||||
raise ValueError("patch must end with *** End Patch")
|
||||
|
||||
ops: List[Dict[str, Any]] = []
|
||||
i = 1
|
||||
while i < len(lines) - 1:
|
||||
line = lines[i]
|
||||
if not line:
|
||||
i += 1
|
||||
continue
|
||||
if line.startswith("*** Add File: "):
|
||||
path = line[len("*** Add File: "):].strip()
|
||||
body = []
|
||||
i += 1
|
||||
while i < len(lines) - 1 and not lines[i].startswith("*** "):
|
||||
if not lines[i].startswith("+"):
|
||||
raise ValueError(f"add file {path}: every content line must start with +")
|
||||
body.append(lines[i][1:])
|
||||
i += 1
|
||||
ops.append({"kind": "add", "path": path, "content": "\n".join(body) + ("\n" if body else "")})
|
||||
continue
|
||||
if line.startswith("*** Delete File: "):
|
||||
path = line[len("*** Delete File: "):].strip()
|
||||
ops.append({"kind": "delete", "path": path})
|
||||
i += 1
|
||||
continue
|
||||
if line.startswith("*** Update File: "):
|
||||
path = line[len("*** Update File: "):].strip()
|
||||
hunks = []
|
||||
current = []
|
||||
i += 1
|
||||
if i < len(lines) - 1 and lines[i].startswith("*** Move to: "):
|
||||
raise ValueError("move operations are not supported")
|
||||
while i < len(lines) - 1 and not lines[i].startswith("*** "):
|
||||
if lines[i].startswith("@@"):
|
||||
if current:
|
||||
hunks.append(current)
|
||||
current = []
|
||||
elif lines[i].startswith((" ", "-", "+")):
|
||||
current.append(lines[i])
|
||||
elif lines[i] == "":
|
||||
current.append(" ")
|
||||
else:
|
||||
raise ValueError(f"update file {path}: invalid patch line {lines[i]!r}")
|
||||
i += 1
|
||||
if current:
|
||||
hunks.append(current)
|
||||
if not hunks:
|
||||
raise ValueError(f"update file {path}: no hunks")
|
||||
ops.append({"kind": "update", "path": path, "hunks": hunks})
|
||||
continue
|
||||
raise ValueError(f"unexpected patch line: {line!r}")
|
||||
return ops
|
||||
|
||||
def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str:
|
||||
updated = original
|
||||
for idx, hunk in enumerate(hunks, 1):
|
||||
old_lines = []
|
||||
new_lines = []
|
||||
for line in hunk:
|
||||
prefix, body = line[:1], line[1:]
|
||||
if prefix in (" ", "-"):
|
||||
old_lines.append(body)
|
||||
if prefix in (" ", "+"):
|
||||
new_lines.append(body)
|
||||
old_text = "\n".join(old_lines)
|
||||
new_text = "\n".join(new_lines)
|
||||
if old_text and old_text in updated:
|
||||
occurrences = updated.count(old_text)
|
||||
if occurrences != 1:
|
||||
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
|
||||
updated = updated.replace(old_text, new_text, 1)
|
||||
elif old_text + "\n" in updated:
|
||||
occurrences = updated.count(old_text + "\n")
|
||||
if occurrences != 1:
|
||||
raise ValueError(f"{label}: hunk {idx} context matched {occurrences} times")
|
||||
updated = updated.replace(old_text + "\n", new_text + "\n", 1)
|
||||
else:
|
||||
raise ValueError(f"{label}: hunk {idx} context not found")
|
||||
return updated
|
||||
|
||||
class LsTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _resolve_tool_path, _resolve_search_root, _truncate
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import collections
|
||||
@@ -10,6 +13,175 @@ DEFAULT_PYTHON_TIMEOUT = 60 * 60
|
||||
|
||||
PROGRESS_INTERVAL_S = 2.0
|
||||
PROGRESS_TAIL_LINES = 12
|
||||
TMUX_CAPTURE_LINES = 2000
|
||||
|
||||
|
||||
def _tmux_session_name(session_id: Optional[str]) -> str:
|
||||
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
|
||||
return f"ody-agent-{raw[:80] or 'default'}"
|
||||
|
||||
|
||||
async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return "", "timeout", 124
|
||||
return (
|
||||
out_b.decode("utf-8", errors="replace"),
|
||||
err_b.decode("utf-8", errors="replace"),
|
||||
proc.returncode or 0,
|
||||
)
|
||||
|
||||
|
||||
async def _tmux_has_session(name: str) -> bool:
|
||||
_, _, rc = await _run_exec("tmux", "has-session", "-t", name, timeout=3)
|
||||
return rc == 0
|
||||
|
||||
|
||||
async def _tmux_capture(name: str) -> str:
|
||||
out, _, _ = await _run_exec(
|
||||
"tmux", "capture-pane", "-p", "-J", "-S", f"-{TMUX_CAPTURE_LINES}", "-t", name,
|
||||
timeout=5,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def _tmux_send_line(name: str, line: str) -> None:
|
||||
if line:
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "-l", line, timeout=5)
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "C-m", timeout=5)
|
||||
|
||||
|
||||
async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None:
|
||||
if await _tmux_has_session(name):
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
|
||||
return
|
||||
await _run_exec(
|
||||
"tmux", "new-session", "-d", "-s", name, "-c", cwd,
|
||||
"env",
|
||||
f"TERM={env.get('TERM', 'xterm-256color') if env else 'xterm-256color'}",
|
||||
f"COLUMNS={env.get('COLUMNS', '120') if env else '120'}",
|
||||
f"LINES={env.get('LINES', '40') if env else '40'}",
|
||||
"/bin/bash",
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
timeout=10,
|
||||
)
|
||||
if not await _tmux_has_session(name):
|
||||
raise RuntimeError(f"failed to create tmux session {name}")
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5)
|
||||
|
||||
|
||||
def _output_after_marker(capture: str, start_marker: str, end_marker: str) -> Tuple[str, bool]:
|
||||
lines = capture.splitlines()
|
||||
start_idx = -1
|
||||
for idx, line in enumerate(lines):
|
||||
if line.strip() == start_marker:
|
||||
start_idx = idx
|
||||
if start_idx < 0:
|
||||
return capture, False
|
||||
end_idx = -1
|
||||
for idx in range(start_idx + 1, len(lines)):
|
||||
if lines[idx].strip().startswith(end_marker):
|
||||
end_idx = idx
|
||||
if end_idx < 0:
|
||||
return "\n".join(lines[start_idx + 1:]), False
|
||||
return "\n".join(lines[start_idx + 1:end_idx]), True
|
||||
|
||||
|
||||
def _extract_marker_rc(capture: str, end_marker: str) -> int:
|
||||
for line in reversed(capture.splitlines()):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(end_marker):
|
||||
suffix = stripped[len(end_marker):].strip()
|
||||
if suffix.isdigit():
|
||||
return int(suffix)
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_tmux_bash(
|
||||
content: str,
|
||||
*,
|
||||
session_id: str,
|
||||
cwd: str,
|
||||
env: Optional[dict],
|
||||
timeout: float,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
) -> Tuple[str, str, Optional[int], bool]:
|
||||
name = _tmux_session_name(session_id)
|
||||
await _ensure_tmux_session(name, cwd, env)
|
||||
|
||||
stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}"
|
||||
start_marker = f"__ODYSSEUS_CMD_START_{stamp}__"
|
||||
end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:"
|
||||
wrapped = (
|
||||
f"printf '\\n{start_marker}\\n'\n"
|
||||
f"{content}\n"
|
||||
f"__ody_rc=$?\n"
|
||||
f"printf '\\n{end_prefix}%s\\n' \"$__ody_rc\"\n"
|
||||
)
|
||||
for line in wrapped.splitlines():
|
||||
await _tmux_send_line(name, line)
|
||||
|
||||
started = time.time()
|
||||
last_tail = ""
|
||||
while True:
|
||||
capture = await _tmux_capture(name)
|
||||
body, done = _output_after_marker(capture, start_marker, end_prefix)
|
||||
tail = "\n".join(body.splitlines()[-PROGRESS_TAIL_LINES:])
|
||||
if progress_cb and tail != last_tail:
|
||||
last_tail = tail
|
||||
try:
|
||||
await progress_cb({
|
||||
"elapsed_s": round(time.time() - started, 1),
|
||||
"tail": tail,
|
||||
"tmux_session": name,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
if done:
|
||||
rc = _extract_marker_rc(capture, end_prefix)
|
||||
cleaned = _clean_tmux_command_output(body, wrapped)
|
||||
return cleaned, "", rc, False
|
||||
if time.time() - started > timeout:
|
||||
try:
|
||||
await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
cleaned = _clean_tmux_command_output(body, wrapped)
|
||||
return cleaned, "", 124, True
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
def _clean_tmux_command_output(text: str, wrapped_command: str) -> str:
|
||||
lines = text.splitlines()
|
||||
wrapped_lines = {ln.rstrip() for ln in wrapped_command.splitlines() if ln.strip()}
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
raw = line.rstrip()
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
cleaned.append(raw)
|
||||
continue
|
||||
if stripped in wrapped_lines:
|
||||
continue
|
||||
if stripped.startswith("__ody_rc=") or stripped.startswith("printf "):
|
||||
continue
|
||||
if re.fullmatch(r"(?:bash|sh)-[\d.]+\$ ?", stripped):
|
||||
continue
|
||||
if re.fullmatch(r"[\w.@:/~+-]+[#$] ?", stripped):
|
||||
continue
|
||||
cleaned.append(raw)
|
||||
return "\n".join(cleaned).strip()
|
||||
|
||||
async def _run_subprocess_streaming(
|
||||
proc: asyncio.subprocess.Process,
|
||||
@@ -103,8 +275,38 @@ async def _run_subprocess_streaming(
|
||||
class BashTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import agent_cwd, _truncate
|
||||
if isinstance(content, dict):
|
||||
content = str(content.get("command") or content.get("cmd") or content.get("code") or "")
|
||||
progress_cb = ctx.get("progress_cb")
|
||||
_subproc_env = ctx.get("subproc_env")
|
||||
session_id = ctx.get("session_id")
|
||||
if session_id and shutil.which("tmux"):
|
||||
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
||||
content,
|
||||
session_id=str(session_id),
|
||||
cwd=agent_cwd(),
|
||||
env=_subproc_env,
|
||||
timeout=DEFAULT_BASH_TIMEOUT,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
if timed_out:
|
||||
return {
|
||||
"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — sent Ctrl-C to tmux session",
|
||||
"exit_code": 124,
|
||||
"stdout": _truncate(stdout, MAX_OUTPUT_CHARS),
|
||||
"stderr": _truncate(stderr, MAX_OUTPUT_CHARS),
|
||||
"tmux_session": _tmux_session_name(str(session_id)),
|
||||
}
|
||||
output = stdout.rstrip()
|
||||
err = stderr.rstrip()
|
||||
if err:
|
||||
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
|
||||
return {
|
||||
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
|
||||
"exit_code": rc or 0,
|
||||
"tmux_session": _tmux_session_name(str(session_id)),
|
||||
}
|
||||
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
content,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
|
||||
+326
-4
@@ -19,7 +19,7 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
@@ -71,9 +71,10 @@ def set_rag_manager(rag_mgr, personal_docs_mgr=None):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, resolve_endpoint_runtime
|
||||
from src.image_model_ids import looks_like_image_generation_model, model_id_leaf
|
||||
|
||||
|
||||
def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Dict]:
|
||||
def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[str] = None) -> Tuple[str, str, Dict]:
|
||||
"""Resolve a model specifier to (endpoint_url, model_id, headers).
|
||||
|
||||
Accepts:
|
||||
@@ -97,9 +98,29 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
else:
|
||||
model_name = spec
|
||||
|
||||
def _json_list(value) -> list[str]:
|
||||
try:
|
||||
data = json.loads(value or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [str(x) for x in data if isinstance(x, (str, int, float)) and str(x)]
|
||||
|
||||
def _image_like(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
if looks_like_image_generation_model(n):
|
||||
return True
|
||||
return any(k in n for k in (
|
||||
"qwen-image", "qwen/image", "z-image", "flux", "stable-diffusion",
|
||||
"sdxl", "hidream", "boogu", "krea-2", "image-edit",
|
||||
))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if model_type:
|
||||
query = query.filter(ModelEndpoint.model_type == model_type)
|
||||
if target_endpoint_name:
|
||||
query = query.filter(ModelEndpoint.name.ilike(f"%{target_endpoint_name}%"))
|
||||
if owner:
|
||||
@@ -129,11 +150,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
return build_chat_url(base), matched, headers
|
||||
else:
|
||||
# OpenAI-compatible and native Ollama: probe the provider's model list.
|
||||
endpoint_reachable = False
|
||||
try:
|
||||
models_url = build_models_url(base)
|
||||
if models_url:
|
||||
r = httpx.get(models_url, headers=headers, timeout=5)
|
||||
r.raise_for_status()
|
||||
endpoint_reachable = True
|
||||
data = r.json()
|
||||
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||
model_ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
@@ -144,10 +167,21 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
else:
|
||||
endpoint_reachable = True
|
||||
model_ids = json.loads(ep.cached_models or "[]")
|
||||
except Exception:
|
||||
model_ids = []
|
||||
|
||||
# Manual/local image endpoints are often registered with pinned
|
||||
# model ids, while /models may return a runtime alias or only the
|
||||
# served internal id. Include pinned/cached ids in the match set
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if extra not in model_ids:
|
||||
model_ids.append(extra)
|
||||
|
||||
# Exact match first
|
||||
for mid in model_ids:
|
||||
if mid.lower() == model_name.lower():
|
||||
@@ -158,6 +192,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None) -> Tuple[str, str, Di
|
||||
if model_name.lower() in mid.lower() or mid.lower() in model_name.lower():
|
||||
return build_chat_url(base), mid, headers
|
||||
|
||||
# Last resort for local image endpoints: if the requested model
|
||||
# name is clearly an image model, use the endpoint's first known
|
||||
# image model id. This prevents a harmless alias mismatch from
|
||||
# blocking image generation.
|
||||
if model_type == "image" and _image_like(model_name) and model_ids:
|
||||
return build_chat_url(base), model_ids[0], headers
|
||||
|
||||
raise ValueError(f"Model '{spec}' not found on any configured endpoint")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -967,16 +1008,36 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
if not model_spec:
|
||||
return {"error": "No image model found. Configure one in Admin → Image Generation."}
|
||||
|
||||
async def _resolve_image_model(model_name: str):
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_name, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
if "model_type" not in str(exc):
|
||||
raise
|
||||
return _resolve_model(model_name, owner=owner)
|
||||
return await asyncio.to_thread(_call)
|
||||
|
||||
# Resolve the model to find the right endpoint
|
||||
try:
|
||||
try:
|
||||
url, model_id, headers = await _resolve_image_model(model_spec)
|
||||
except ValueError:
|
||||
_lower_model_spec = model_spec.lower()
|
||||
if not (
|
||||
any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e"))
|
||||
or looks_like_image_generation_model(_lower_model_spec)
|
||||
):
|
||||
raise
|
||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'. "
|
||||
"Configure an OpenAI-compatible endpoint with image generation support."}
|
||||
|
||||
# Detect if this is a GPT image model vs DALL-E vs local diffusion
|
||||
is_gpt_image = "gpt-image" in model_id.lower()
|
||||
is_dalle = "dall-e" in model_id.lower()
|
||||
_model_leaf = model_id_leaf(model_id)
|
||||
is_gpt_image = _model_leaf.startswith("gpt-image") or (_model_leaf.startswith("gpt-") and "-image" in _model_leaf)
|
||||
is_dalle = _model_leaf.startswith("dall-e")
|
||||
is_local_diffusion = not is_gpt_image and not is_dalle
|
||||
|
||||
# Build the images endpoint URL from the chat completions URL
|
||||
@@ -1106,6 +1167,267 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
return {"error": f"Image generation error: {str(e)}"}
|
||||
|
||||
|
||||
async def do_edit_image(
|
||||
prompt: str,
|
||||
image_path: str,
|
||||
model_spec: str = "",
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
size: str = "1024x1024",
|
||||
quality: str = "medium",
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Dict:
|
||||
"""Edit an uploaded image using the configured image endpoint."""
|
||||
import base64
|
||||
import httpx
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
return {"error": "Image edit prompt is required"}
|
||||
path = Path(image_path)
|
||||
if not path.exists() or not path.is_file():
|
||||
return {"error": "Attached image file was not found"}
|
||||
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
if not model_spec:
|
||||
return {"error": "No image model selected for image editing"}
|
||||
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
if "model_type" not in str(exc):
|
||||
raise
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
url, model_id, headers = await asyncio.to_thread(_call)
|
||||
except ValueError:
|
||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, owner=owner)
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
edits_url = base_url + "/images/edits"
|
||||
mime = mimetypes.guess_type(str(path))[0] or "image/png"
|
||||
payload = {
|
||||
"model": model_id,
|
||||
"prompt": prompt,
|
||||
"n": "1",
|
||||
"size": size,
|
||||
"quality": quality if quality in ("low", "medium", "high", "auto") else "medium",
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
request_id = uuid.uuid4().hex
|
||||
payload["request_id"] = request_id
|
||||
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
|
||||
|
||||
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||
try:
|
||||
from src.database import SessionLocal as _GallerySL, GalleryImage
|
||||
new_id = str(uuid.uuid4())
|
||||
_gdb = _GallerySL()
|
||||
_gdb.add(GalleryImage(
|
||||
id=new_id,
|
||||
filename=filename,
|
||||
prompt=prompt,
|
||||
model=model_id,
|
||||
size=size,
|
||||
quality=payload.get("quality", "medium"),
|
||||
session_id=session_id,
|
||||
owner=owner,
|
||||
))
|
||||
_gdb.commit()
|
||||
_gdb.close()
|
||||
return new_id
|
||||
except Exception as _ge:
|
||||
logger.warning("Failed to save edited image gallery record: %s", _ge)
|
||||
return ""
|
||||
|
||||
def _save_image_bytes(image_bytes: bytes, suffix: str = ".png") -> tuple[str, str]:
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
(img_dir / filename).write_bytes(image_bytes)
|
||||
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
|
||||
|
||||
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
|
||||
"""Try Odysseus' local diffusion img2img endpoint.
|
||||
|
||||
Some self-hosted SD/SDXL endpoints expose text-to-image plus
|
||||
`/images/harmonize`/img2img, but not OpenAI's multipart
|
||||
`/images/edits`. For chat uploads ("image + prompt"), this gives the
|
||||
expected instruction-edit behavior instead of stopping at a 400.
|
||||
"""
|
||||
harmonize_url = base_url + "/images/harmonize"
|
||||
try:
|
||||
image_bytes = path.read_bytes()
|
||||
image_b64 = base64.b64encode(image_bytes).decode()
|
||||
fallback_payload = {
|
||||
"image": image_b64,
|
||||
"prompt": prompt,
|
||||
"strength": 0.35,
|
||||
"steps": 0,
|
||||
"max_side": 1024,
|
||||
}
|
||||
if progress_callback:
|
||||
await progress_callback({
|
||||
"status": "running",
|
||||
"message": "Trying image-to-image fallback",
|
||||
"step": 0,
|
||||
"total": 0,
|
||||
})
|
||||
fallback_resp = await client.post(harmonize_url, json=fallback_payload, headers=headers)
|
||||
if fallback_resp.status_code == 404:
|
||||
return None
|
||||
if fallback_resp.status_code != 200:
|
||||
error_text = fallback_resp.text[:500]
|
||||
try:
|
||||
err_json = fallback_resp.json()
|
||||
error_text = err_json.get("detail") or err_json.get("error") or error_text
|
||||
except Exception:
|
||||
pass
|
||||
return {"error": f"Image edit fallback failed ({fallback_resp.status_code}): {error_text}"}
|
||||
fallback_data = fallback_resp.json()
|
||||
image_b64 = fallback_data.get("image")
|
||||
if not image_b64:
|
||||
return {"error": "Image edit fallback returned no image"}
|
||||
image_url, image_id = _save_image_bytes(base64.b64decode(image_b64))
|
||||
return {
|
||||
"results": f"Edited image for: {prompt[:100]}",
|
||||
"image_url": image_url,
|
||||
"image_id": image_id,
|
||||
"image_prompt": prompt,
|
||||
"image_model": model_id,
|
||||
"image_size": size,
|
||||
"image_quality": payload.get("quality", "medium"),
|
||||
"edit_route": "img2img",
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Image edit fallback timed out. The model may still be loading or overloaded."}
|
||||
except Exception as fallback_error:
|
||||
logger.warning("Image edit fallback failed: %s", fallback_error)
|
||||
return {"error": f"Image edit fallback error: {fallback_error}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=600.0, write=60.0, pool=30.0)) as client:
|
||||
progress_task = None
|
||||
if progress_callback:
|
||||
progress_url = base_url + f"/images/progress/{request_id}"
|
||||
|
||||
async def _poll_progress():
|
||||
last_sig = None
|
||||
while True:
|
||||
try:
|
||||
pr = await client.get(progress_url, headers=headers, timeout=5.0)
|
||||
if pr.status_code == 404:
|
||||
return
|
||||
if pr.status_code == 200:
|
||||
data = pr.json()
|
||||
sig = (data.get("status"), data.get("step"), data.get("total"), data.get("percent"))
|
||||
if sig != last_sig:
|
||||
last_sig = sig
|
||||
await progress_callback(data)
|
||||
if data.get("status") in {"done", "error"}:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
progress_task = asyncio.create_task(_poll_progress())
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
files = {"image": (path.name, f, mime)}
|
||||
resp = await client.post(edits_url, data=payload, files=files, headers=headers)
|
||||
finally:
|
||||
if progress_task:
|
||||
progress_task.cancel()
|
||||
try:
|
||||
await progress_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
try:
|
||||
err_json = resp.json()
|
||||
err = err_json.get("error")
|
||||
error_text = (
|
||||
err.get("message", error_text)
|
||||
if isinstance(err, dict)
|
||||
else str(err or err_json.get("detail") or error_text)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if resp.status_code in (400, 404, 405, 422):
|
||||
fallback = await _try_local_img2img_fallback(client)
|
||||
if fallback:
|
||||
return fallback
|
||||
if resp.status_code == 404:
|
||||
return {
|
||||
"error": (
|
||||
f"Image model '{model_id}' is reachable, but this endpoint does not expose image editing. "
|
||||
"Use it without an attached image for text-to-image generation, or serve an edit/img2img "
|
||||
"model for attached-image prompts."
|
||||
)
|
||||
}
|
||||
return {"error": f"Image edit failed ({resp.status_code}): {error_text}"}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
if not images:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
image_id = None
|
||||
|
||||
if img.get("b64_json"):
|
||||
image_url, image_id = _save_image_bytes(base64.b64decode(img.get("b64_json")))
|
||||
elif img.get("url"):
|
||||
result_url = img["url"]
|
||||
ok, reason = check_outbound_url(
|
||||
result_url,
|
||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
return {"error": f"Image edit API returned unsafe image URL: {reason}"}
|
||||
dl_resp = httpx.get(result_url, timeout=60)
|
||||
if dl_resp.status_code != 200:
|
||||
return {"error": f"Could not download edited image ({dl_resp.status_code})"}
|
||||
image_url, image_id = _save_image_bytes(dl_resp.content)
|
||||
else:
|
||||
return {"error": "Image edit API returned unexpected format (no b64_json or url)"}
|
||||
|
||||
return {
|
||||
"results": f"Edited image for: {prompt[:100]}",
|
||||
"image_url": image_url,
|
||||
"image_id": image_id,
|
||||
"image_prompt": prompt,
|
||||
"image_model": model_id,
|
||||
"image_size": size,
|
||||
"image_quality": payload.get("quality", "medium"),
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "Image edit timed out. The model may still be loading or overloaded."}
|
||||
except Exception as e:
|
||||
return {"error": f"Image edit error: {str(e)}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher (called from agent_tools.execute_tool_block)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+71
-2
@@ -77,6 +77,7 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
try:
|
||||
import json
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from src.constants import DATA_DIR
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
from src.memory import MemoryManager
|
||||
@@ -112,6 +113,64 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
ai_reasons = []
|
||||
ai_used = False
|
||||
|
||||
def _normalized_memory_text(mem: dict) -> str:
|
||||
text = (mem.get("text") or "").lower()
|
||||
text = re.sub(r"[^a-z0-9@._+-]+", " ", text)
|
||||
return " ".join(text.split())
|
||||
|
||||
def _memory_rank(mem: dict) -> tuple:
|
||||
text = (mem.get("text") or "").strip()
|
||||
return (
|
||||
1 if mem.get("pinned") else 0,
|
||||
1 if (mem.get("source") or "") == "user" else 0,
|
||||
int(mem.get("uses") or 0),
|
||||
-len(text),
|
||||
int(mem.get("timestamp") or 0),
|
||||
)
|
||||
|
||||
def _same_memory_fact(a: dict, b: dict) -> bool:
|
||||
a_cat = (a.get("category") or "fact").strip().lower()
|
||||
b_cat = (b.get("category") or "fact").strip().lower()
|
||||
if a_cat != b_cat:
|
||||
return False
|
||||
a_text = _normalized_memory_text(a)
|
||||
b_text = _normalized_memory_text(b)
|
||||
if not a_text or not b_text:
|
||||
return False
|
||||
if a_text == b_text:
|
||||
return True
|
||||
shorter, longer = sorted((a_text, b_text), key=len)
|
||||
if len(shorter) >= 24 and shorter in longer:
|
||||
return True
|
||||
return SequenceMatcher(None, a_text, b_text).ratio() >= 0.88
|
||||
|
||||
def _dedupe_group(group_memories: list) -> tuple[list, int]:
|
||||
kept = []
|
||||
removed = 0
|
||||
for mem in group_memories:
|
||||
text = (mem.get("text") or "").strip()
|
||||
if not text:
|
||||
removed += 1
|
||||
if len(removed_examples) < 3:
|
||||
removed_examples.append("(empty)")
|
||||
continue
|
||||
duplicate_idx = next(
|
||||
(idx for idx, kept_mem in enumerate(kept) if _same_memory_fact(mem, kept_mem)),
|
||||
None,
|
||||
)
|
||||
if duplicate_idx is None:
|
||||
kept.append(mem)
|
||||
continue
|
||||
removed += 1
|
||||
if _memory_rank(mem) > _memory_rank(kept[duplicate_idx]):
|
||||
if len(removed_examples) < 3:
|
||||
old_text = (kept[duplicate_idx].get("text") or "").strip()
|
||||
removed_examples.append(old_text[:60] + ("..." if len(old_text) > 60 else ""))
|
||||
kept[duplicate_idx] = mem
|
||||
elif len(removed_examples) < 3:
|
||||
removed_examples.append(text[:60] + ("..." if len(text) > 60 else ""))
|
||||
return kept, removed
|
||||
|
||||
async def _try_ai_tidy_group(group_owner: str, group_memories: list) -> bool:
|
||||
nonlocal all_memories, total_removed, total_cleaned, total_scanned, ai_used
|
||||
if len(group_memories) < 2:
|
||||
@@ -220,7 +279,6 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
kept_all.append(mem)
|
||||
|
||||
removed = sum(1 for m in group_memories if m.get("id") in drop_ids)
|
||||
total_scanned += len(group_memories)
|
||||
if removed or changed_text:
|
||||
all_memories = kept_all
|
||||
total_removed += removed
|
||||
@@ -237,12 +295,23 @@ async def action_consolidate_memory(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
return False
|
||||
|
||||
for group_owner, group_memories in memory_groups.items():
|
||||
total_scanned += len(group_memories)
|
||||
deduped_group, group_removed = _dedupe_group(group_memories)
|
||||
if group_removed:
|
||||
group_ref_ids = {id(m) for m in group_memories}
|
||||
keep_ref_ids = {id(m) for m in deduped_group}
|
||||
all_memories = [
|
||||
m for m in all_memories
|
||||
if id(m) not in group_ref_ids or id(m) in keep_ref_ids
|
||||
]
|
||||
total_removed += group_removed
|
||||
group_memories = deduped_group
|
||||
|
||||
if await _try_ai_tidy_group(group_owner, group_memories):
|
||||
continue
|
||||
|
||||
seen = {}
|
||||
keep_refs = set()
|
||||
total_scanned += len(group_memories)
|
||||
for mem in group_memories:
|
||||
text = (mem.get("text") or "").strip()
|
||||
key = " ".join(text.lower().split())
|
||||
|
||||
+61
-15
@@ -87,6 +87,7 @@ _BUILTIN_NPX_SERVERS = {
|
||||
|
||||
# Global flag to disable MCP if there are compatibility issues
|
||||
MCP_DISABLED = os.environ.get("ODYSSEUS_DISABLE_MCP", "").lower() in ("1", "true", "yes")
|
||||
BROWSER_MCP_REQUIRE_CACHE = os.environ.get("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
# Strong references to the fire-and-forget startup tasks scheduled below.
|
||||
@@ -103,6 +104,46 @@ def _spawn_bg(coro) -> asyncio.Task:
|
||||
task.add_done_callback(_BG_TASKS.discard)
|
||||
return task
|
||||
|
||||
def _find_browser_executable() -> str:
|
||||
"""Find a browser binary for the built-in Playwright MCP server.
|
||||
|
||||
Docker images ship Debian's `chromium`; desktop installs may already have
|
||||
Chrome/Chromium in a conventional location. If nothing is found, return an
|
||||
empty string and let Playwright MCP use its own default browser/channel.
|
||||
"""
|
||||
configured = os.environ.get("ODYSSEUS_BROWSER_EXECUTABLE", "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
for name in ("google-chrome", "chromium", "chromium-browser"):
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
for candidate in (
|
||||
"/opt/google/chrome/chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
):
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _browser_mcp_args(args: list[str]) -> list[str]:
|
||||
"""Return Playwright MCP args with a concrete browser executable when found."""
|
||||
out = list(args or [])
|
||||
if "--executable-path" not in out:
|
||||
browser = _find_browser_executable()
|
||||
if browser:
|
||||
out.extend(["--executable-path", browser])
|
||||
if os.environ.get("ODYSSEUS_BROWSER_ISOLATED", "1").lower() not in ("0", "false", "no"):
|
||||
if "--isolated" not in out and "--user-data-dir" not in out:
|
||||
out.append("--isolated")
|
||||
if os.environ.get("ODYSSEUS_BROWSER_NO_SANDBOX", "1").lower() not in ("0", "false", "no"):
|
||||
if "--no-sandbox" not in out and "--sandbox" not in out:
|
||||
out.append("--no-sandbox")
|
||||
return out
|
||||
|
||||
|
||||
def builtin_python_env(base_dir: str) -> dict[str, str]:
|
||||
"""Environment for built-in Python MCP subprocesses.
|
||||
@@ -162,39 +203,44 @@ async def register_builtin_servers(mcp_manager):
|
||||
async def _start_npx_servers():
|
||||
await asyncio.sleep(3) # let Python servers finish first
|
||||
for server_id, cfg in _BUILTIN_NPX_SERVERS.items():
|
||||
# Skip the server if its npx package isn't cached. Without this
|
||||
# check, npx would try to download/install the package on first
|
||||
# use, which can take minutes (or hang) on fresh installs without
|
||||
# Playwright system deps. Wrapping that in asyncio.wait_for to
|
||||
# bound the wait sounds reasonable, but mcp.client.stdio uses an
|
||||
# internal anyio task group that can't survive the resulting
|
||||
# cross-task cancellation: it raises "Attempted to exit cancel
|
||||
# scope in a different task than it was entered in" in a sibling
|
||||
# task, which cascades cancellations into the rest of the event
|
||||
# loop and downs the app. Detecting installed-state up-front lets
|
||||
# us bail with a useful warning before we ever touch stdio_client.
|
||||
args = cfg["args"]
|
||||
# Browser automation is a shipped built-in, so the default path
|
||||
# lets `npx -y` install @playwright/mcp on first start. Locked-down
|
||||
# installs can opt back into the old no-network startup behavior
|
||||
# with ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1.
|
||||
args = _browser_mcp_args(cfg["args"]) if server_id == "builtin_browser" else list(cfg["args"])
|
||||
pkg_spec = _npx_package_from_args(args)
|
||||
if pkg_spec and not await _is_npx_package_cached(npx_path, pkg_spec):
|
||||
if BROWSER_MCP_REQUIRE_CACHE and pkg_spec and not await _is_npx_package_cached(npx_path, pkg_spec):
|
||||
logger.warning(
|
||||
f"{cfg['name']} is not available.\n"
|
||||
f" Reason: npm package {pkg_spec!r} is not installed in the npx cache.\n"
|
||||
f" Impact: tools provided by this MCP server will be unavailable.\n"
|
||||
f" Fix: {os.path.basename(npx_path)} -y {pkg_spec} --version\n"
|
||||
f" (run once, then restart Odysseus)\n"
|
||||
f" Notes: this server is optional; see README.md "
|
||||
f"'Built-in MCP servers' for details."
|
||||
f" Notes: ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1 is set, "
|
||||
f"so Odysseus will not install browser automation on startup."
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(f"Starting NPX server: {cfg['name']} ({npx_path} {' '.join(args)})")
|
||||
try:
|
||||
env = None
|
||||
if server_id == "builtin_browser":
|
||||
cache_home = os.environ.get(
|
||||
"ODYSSEUS_BROWSER_MCP_CACHE",
|
||||
os.path.join(base_dir, "data", "local", "playwright-mcp-cache"),
|
||||
)
|
||||
os.makedirs(cache_home, exist_ok=True)
|
||||
env = {
|
||||
"XDG_CACHE_HOME": cache_home,
|
||||
"PLAYWRIGHT_BROWSERS_PATH": os.path.join(cache_home, "browsers"),
|
||||
}
|
||||
ok = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=cfg["name"],
|
||||
transport="stdio",
|
||||
command=npx_path,
|
||||
args=args,
|
||||
env=env,
|
||||
)
|
||||
if ok:
|
||||
logger.info(f"Built-in NPX server registered: {cfg['name']}")
|
||||
|
||||
+78
-8
@@ -89,6 +89,71 @@ class ChatProcessor:
|
||||
|
||||
# Minimum similarity score for RAG results to be injected
|
||||
RAG_SIMILARITY_THRESHOLD = 0.35
|
||||
MEMORY_CONTEXT_LIMIT = 5
|
||||
PINNED_MEMORY_LIMIT = MEMORY_CONTEXT_LIMIT
|
||||
|
||||
def _is_core_memory(self, memory: Dict[str, Any]) -> bool:
|
||||
"""Return whether a pinned memory is safe to keep globally available."""
|
||||
category = (memory.get("category") or "").lower()
|
||||
if category in {"identity", "contact"}:
|
||||
return True
|
||||
text = (memory.get("text") or "").lower()
|
||||
return any(marker in text for marker in (
|
||||
"my name is",
|
||||
"name is",
|
||||
"call me",
|
||||
"i am ",
|
||||
"i'm ",
|
||||
"email",
|
||||
"phone",
|
||||
"address",
|
||||
))
|
||||
|
||||
def _select_pinned_memories(self, message: str, pinned: list) -> list:
|
||||
"""Keep pinned memories high-priority without injecting all of them.
|
||||
|
||||
Pinned used to mean "always send every pinned memory to the model".
|
||||
That bloats every request and leaks unrelated personal context into
|
||||
tasks that do not need it. Now only a small set of core identity/contact
|
||||
memories is always available; other pinned memories must match the
|
||||
current request, but are retrieved before ordinary memories.
|
||||
"""
|
||||
if not pinned:
|
||||
return []
|
||||
|
||||
def _recent_first(memory: Dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(memory.get("timestamp") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
core = sorted(
|
||||
[m for m in pinned if self._is_core_memory(m)],
|
||||
key=_recent_first,
|
||||
reverse=True,
|
||||
)[:self.PINNED_MEMORY_LIMIT]
|
||||
|
||||
core_ids = {m.get("id") for m in core if m.get("id")}
|
||||
contextual_candidates = [
|
||||
m for m in pinned
|
||||
if not (m.get("id") and m.get("id") in core_ids)
|
||||
]
|
||||
remaining_slots = max(self.PINNED_MEMORY_LIMIT - len(core), 0)
|
||||
contextual = self._hybrid_retrieve(
|
||||
message,
|
||||
contextual_candidates,
|
||||
k=remaining_slots,
|
||||
) if remaining_slots else []
|
||||
|
||||
selected = []
|
||||
seen = set()
|
||||
for memory in [*core, *contextual]:
|
||||
key = memory.get("id") or memory.get("text")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
selected.append(memory)
|
||||
return selected[:self.PINNED_MEMORY_LIMIT]
|
||||
|
||||
def _hybrid_retrieve(self, message: str, mem_entries: list, k: int = 5) -> list:
|
||||
"""Retrieve memories relevant to the message.
|
||||
@@ -242,7 +307,7 @@ class ChatProcessor:
|
||||
"content": UNTRUSTED_CONTEXT_POLICY,
|
||||
})
|
||||
|
||||
# Memory: pinned (always included) + extended (RAG-retrieved when relevant)
|
||||
# Memory: core pinned facts + relevant pinned/extended recall.
|
||||
self._last_used_memories = [] # track what was injected
|
||||
if use_memory:
|
||||
mem_entries = self.memory_manager.load(owner=owner)
|
||||
@@ -251,19 +316,24 @@ class ChatProcessor:
|
||||
extended = [m for m in mem_entries if not m.get("pinned")]
|
||||
|
||||
_used_ids: list = []
|
||||
if pinned:
|
||||
pinned_text = "\n- ".join([m["text"] for m in pinned])
|
||||
selected_pinned = self._select_pinned_memories(message, pinned)
|
||||
if selected_pinned:
|
||||
pinned_text = "\n- ".join([m["text"] for m in selected_pinned])
|
||||
preface.append(untrusted_context_message(
|
||||
"saved memory: pinned user facts",
|
||||
f"Core facts about the user:\n- {pinned_text}",
|
||||
"saved memory: pinned context",
|
||||
(
|
||||
"Pinned memory context. Some pinned memories are only "
|
||||
f"included when relevant:\n- {pinned_text}"
|
||||
),
|
||||
))
|
||||
for m in pinned:
|
||||
for m in selected_pinned:
|
||||
self._last_used_memories.append({"text": m["text"], "category": m.get("category", "fact"), "type": "pinned"})
|
||||
if m.get("id"):
|
||||
_used_ids.append(m["id"])
|
||||
|
||||
if extended:
|
||||
relevant = self._hybrid_retrieve(message, extended, k=3)
|
||||
remaining_memory_slots = max(self.MEMORY_CONTEXT_LIMIT - len(self._last_used_memories), 0)
|
||||
if extended and remaining_memory_slots:
|
||||
relevant = self._hybrid_retrieve(message, extended, k=remaining_memory_slots)
|
||||
if relevant:
|
||||
ext_text = "\n".join([f"- {m['text']}" for m in relevant])
|
||||
preface.append(untrusted_context_message(
|
||||
|
||||
@@ -7,6 +7,7 @@ Summarizes older messages via the same LLM, preserving key context.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.model_context import get_context_length, estimate_tokens
|
||||
@@ -70,6 +71,14 @@ What is the system/code/task state right now? What was the last thing discussed?
|
||||
Keep the summary under 1000 tokens. Be dense — every token should carry information. Do not include pleasantries or meta-commentary."""
|
||||
|
||||
|
||||
def normalize_compaction_summary(summary: str) -> str:
|
||||
"""Remove redundant leading title text before adding our wrapper."""
|
||||
text = (summary or "").strip()
|
||||
text = re.sub(r"^(?:#{1,3}\s*)?Conversation Summary\s*", "", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"^\*\*Conversation Summary\*\*\s*", "", text, flags=re.IGNORECASE)
|
||||
return text.lstrip()
|
||||
|
||||
|
||||
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
||||
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
|
||||
|
||||
@@ -393,6 +402,7 @@ async def maybe_compact(
|
||||
# silently dropping the older half. was_compacted=False signals the
|
||||
# caller nothing was summarized; trim_for_context handles length.
|
||||
return messages, context_length, False
|
||||
summary = normalize_compaction_summary(summary)
|
||||
|
||||
summary_msg = {
|
||||
"role": "system",
|
||||
@@ -439,6 +449,7 @@ def _update_session_history(session, split_point: int, summary: str,
|
||||
# messages so the system prompt survives compaction.
|
||||
system_prefix = list(session.history[:system_msg_count])
|
||||
recent_history = session.history[effective_split:]
|
||||
summary = normalize_compaction_summary(summary)
|
||||
summary_msg = ChatMessage(
|
||||
role="system",
|
||||
content=f"[Conversation summary]\n{summary}",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Small helpers for recognizing image-generation model IDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
_IMAGE_MODEL_PREFIXES = (
|
||||
"gpt-image",
|
||||
"dall-e",
|
||||
"chatgpt-image",
|
||||
"hidream",
|
||||
"qwen-image",
|
||||
"z-image",
|
||||
"flux",
|
||||
"stable-diffusion",
|
||||
"sdxl",
|
||||
"boogu",
|
||||
"krea-2",
|
||||
)
|
||||
|
||||
|
||||
def model_id_leaf(model_id: str) -> str:
|
||||
"""Return the provider-stripped model id leaf in lowercase."""
|
||||
return str(model_id or "").strip().split("/")[-1].lower()
|
||||
|
||||
|
||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
API providers can namespace image models, e.g. ``openai/gpt-5-image``.
|
||||
Classify by the leaf so mixed endpoints can expose chat and image models
|
||||
without marking the whole endpoint as image-only.
|
||||
"""
|
||||
mid = str(model_id or "").strip().lower()
|
||||
leaf = model_id_leaf(mid)
|
||||
if not leaf:
|
||||
return False
|
||||
if any(leaf.startswith(prefix) for prefix in _IMAGE_MODEL_PREFIXES):
|
||||
return True
|
||||
# Newer OpenAI image models use names like gpt-5-image instead of
|
||||
# gpt-image-1. Keep this pattern provider-agnostic.
|
||||
return leaf.startswith("gpt-") and "-image" in leaf
|
||||
|
||||
@@ -1040,6 +1040,31 @@ def _provider_label(url: str) -> str:
|
||||
return host or "provider"
|
||||
|
||||
|
||||
def _is_openai_hosted_chat_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
except Exception:
|
||||
return False
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
return _host_match(url, "openai.com") and path.endswith("/chat/completions")
|
||||
|
||||
|
||||
def _model_disallows_reasoning_effort_with_chat_tools(model: str) -> bool:
|
||||
"""OpenAI GPT 5.x variants reject reasoning_effort + tools on chat completions."""
|
||||
m = (model or "").strip().lower()
|
||||
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
|
||||
|
||||
|
||||
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
|
||||
if not payload.get("tools"):
|
||||
return
|
||||
if not _is_openai_hosted_chat_url(target_url):
|
||||
return
|
||||
if not _model_disallows_reasoning_effort_with_chat_tools(model):
|
||||
return
|
||||
payload["reasoning_effort"] = "none"
|
||||
|
||||
|
||||
def _normalize_chatgpt_subscription_url(url: str) -> str:
|
||||
base = (url or "").strip().rstrip("/")
|
||||
if base.endswith("/responses"):
|
||||
@@ -2205,6 +2230,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
payload["think"] = False
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
_apply_local_generation_stability(payload, target_url, model)
|
||||
_scrub_openai_chat_tool_reasoning(payload, target_url, model)
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Cleanup helpers for images attached to chat sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _database_models():
|
||||
"""Import DB models at call time so early import stubs cannot stick here."""
|
||||
from core.database import ChatMessage, GalleryImage, SessionLocal
|
||||
|
||||
return ChatMessage, GalleryImage, SessionLocal
|
||||
|
||||
|
||||
def _generated_image_path_for_cleanup(filename: str) -> Path | None:
|
||||
if not isinstance(filename, str) or not filename:
|
||||
return None
|
||||
name = Path(filename).name
|
||||
if name != filename or name in {".", ".."}:
|
||||
return None
|
||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||
path = (root / name).resolve()
|
||||
try:
|
||||
if os.path.commonpath([str(root), str(path)]) != str(root):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _image_filename_from_url(url: str) -> str:
|
||||
if not isinstance(url, str) or not url:
|
||||
return ""
|
||||
match = re.search(r"/api/generated-image/([^?#/]+)", url)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def session_image_refs(db, session_id: str) -> tuple[set[str], set[str]]:
|
||||
"""Return gallery image ids and generated-image filenames referenced by a chat."""
|
||||
ChatMessage, GalleryImage, _ = _database_models()
|
||||
image_ids: set[str] = set()
|
||||
filenames: set[str] = set()
|
||||
|
||||
rows = db.query(GalleryImage).filter(GalleryImage.session_id == session_id).all()
|
||||
for img in rows:
|
||||
if img.id:
|
||||
image_ids.add(str(img.id))
|
||||
if img.filename:
|
||||
filenames.add(str(img.filename))
|
||||
|
||||
messages = db.query(ChatMessage.meta_data).filter(ChatMessage.session_id == session_id).all()
|
||||
for row in messages:
|
||||
raw = getattr(row, "meta_data", None)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
events = meta.get("tool_events") if isinstance(meta, dict) else None
|
||||
if not isinstance(events, list):
|
||||
continue
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
image_id = ev.get("image_id")
|
||||
if image_id:
|
||||
image_ids.add(str(image_id))
|
||||
filename = _image_filename_from_url(ev.get("image_url") or ev.get("url") or "")
|
||||
if filename:
|
||||
filenames.add(filename)
|
||||
|
||||
return image_ids, filenames
|
||||
|
||||
|
||||
def cleanup_session_images(session_id: str, db=None) -> int:
|
||||
"""Soft-delete Gallery rows and unlink generated files owned by a chat."""
|
||||
_, GalleryImage, SessionLocal = _database_models()
|
||||
owns_db = db is None
|
||||
db = db or SessionLocal()
|
||||
try:
|
||||
image_ids, filenames = session_image_refs(db, session_id)
|
||||
query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id)
|
||||
if image_ids or filenames:
|
||||
from sqlalchemy import or_
|
||||
|
||||
clauses = [GalleryImage.session_id == session_id]
|
||||
if image_ids:
|
||||
clauses.append(GalleryImage.id.in_(list(image_ids)))
|
||||
if filenames:
|
||||
clauses.append(GalleryImage.filename.in_(list(filenames)))
|
||||
query = db.query(GalleryImage).filter(or_(*clauses))
|
||||
|
||||
images = query.all()
|
||||
removed = 0
|
||||
for img in images:
|
||||
img.is_active = False
|
||||
if img.filename:
|
||||
path = _generated_image_path_for_cleanup(img.filename)
|
||||
if path and path.exists():
|
||||
try:
|
||||
path.unlink()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not remove generated image %s for deleted session %s: %s",
|
||||
img.filename,
|
||||
session_id,
|
||||
exc,
|
||||
)
|
||||
removed += 1
|
||||
|
||||
if owns_db and images:
|
||||
db.commit()
|
||||
return removed
|
||||
except Exception as exc:
|
||||
if owns_db:
|
||||
db.rollback()
|
||||
logger.warning("Failed to clean images for deleted session %s: %s", session_id, exc)
|
||||
return 0
|
||||
finally:
|
||||
if owns_db:
|
||||
db.close()
|
||||
+3
-5
@@ -64,11 +64,9 @@ DEFAULT_SETTINGS = {
|
||||
"search_url": "",
|
||||
"search_result_count": 5,
|
||||
# SafeSearch level applied to every provider that exposes one.
|
||||
# "strict" — block adult / explicit results (default; matches what users
|
||||
# expect from a research tool and avoids unrelated NSFW URLs
|
||||
# bleeding in via provider "related" / spam recommendations)
|
||||
# "moderate" — provider-default behavior (filter explicit but allow
|
||||
# suggestive content)
|
||||
# "strict" — apply the provider's strongest filtering level (default;
|
||||
# keeps unrelated low-quality/spam recommendations out)
|
||||
# "moderate" — provider-default filtering behavior
|
||||
# "off" — disable filtering entirely (advanced users only)
|
||||
#
|
||||
# Providers that honor this setting (translated to each provider's native
|
||||
|
||||
@@ -752,6 +752,11 @@ async def _execute_tool_block_impl(
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _direct_fallback(tool, content, progress_cb=progress_cb) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool in ("apply_patch", "todowrite"):
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}" if first_line else tool
|
||||
result = await _direct_fallback(tool, content, session_id=session_id, owner=owner) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool == "manage_bg_jobs":
|
||||
# Inspect/kill detached `bash` jobs; needs session_id to scope to chat.
|
||||
desc = f"manage_bg_jobs: {content.split(chr(10))[0][:80]}"
|
||||
|
||||
+11
-5
@@ -47,7 +47,7 @@ ALWAYS_AVAILABLE = frozenset({
|
||||
# Tools that the Personal Assistant always has access to during scheduled
|
||||
# check-ins and proactive tasks, in addition to RAG-selected tools.
|
||||
ASSISTANT_ALWAYS_AVAILABLE = frozenset({
|
||||
"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email",
|
||||
"list_email_accounts", "list_emails", "read_email", "scan_email_unsubscribes", "unsubscribe_email", "send_email", "reply_to_email",
|
||||
"bulk_email", "archive_email", "delete_email", "mark_email_read",
|
||||
"manage_calendar", "manage_notes", "manage_tasks",
|
||||
"manage_memory", "web_search", "read_file",
|
||||
@@ -78,6 +78,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"get_workspace": "Return the absolute path of the active workspace folder the user is working in. File tools are confined to it; the shell starts there but is not sandboxed. Call this first when the user refers to 'the project'/'the code'/'this folder' without giving a path, instead of asking them.",
|
||||
"write_file": "Write/create or fully rewrite a file ON DISK (source code, configs, project files). Use for new files or full rewrites — NOT create_document (editor panel) and NOT a bash heredoc.",
|
||||
"edit_file": "Edit an existing file ON DISK by exact string replacement (fix a bug, change a function). Shows a diff. The tool for changing files on disk — NOT edit_document (editor panel) and NOT bash sed/heredoc.",
|
||||
"apply_patch": "Apply a multi-file patch to source files ON DISK. Use for implementation, refactors, and bug fixes where several edits belong together. Workspace-confined and returns a diff. Prefer over bash redirects/heredocs/sed.",
|
||||
"todowrite": "Maintain a structured task list for the current coding session. Use for multi-step code work: inspect, edit, test, and mark statuses current.",
|
||||
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.",
|
||||
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
|
||||
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
|
||||
@@ -109,6 +111,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
||||
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
|
||||
"scan_email_unsubscribes": "Scan recent email headers for spam/newsletter unsubscribe candidates. Review-only; returns UIDs, reasons, and mailto/web unsubscribe methods.",
|
||||
"unsubscribe_email": "Execute an approved unsubscribe action by UID. Mailto methods are sent/staged; web URL methods return exact browser/web instructions.",
|
||||
"send_email": "Send a new email via SMTP. Provide recipient, subject, body, and optional account from list_email_accounts. For replying to a thread use reply_to_email instead.",
|
||||
"reply_to_email": "SEND a reply email immediately by UID. Do not use for write/draft/open/start reply requests; use ui_control open_email_reply with body so the user can review. Only use when the user explicitly says to send now. For send requests, use the exact UID and account from latest read_email/list_emails output; never invent UID 1. Threads automatically with In-Reply-To/References, prefixes Re:, marks original as Answered.",
|
||||
"archive_email": "Move an email out of the inbox into the Archive folder. Use after handling messages you want to keep but get out of the way.",
|
||||
@@ -127,8 +131,8 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_downloads": "List in-progress HuggingFace model downloads in the Cookbook. Shows model name, phase, percent, session ID. Use for 'what's downloading', 'show my downloads', 'check download progress'.",
|
||||
"cancel_download": "Cancel an in-progress model download by tmux session ID. Use for 'cancel the download', 'stop downloading X', 'kill the download'. Call list_downloads first to get the session_id.",
|
||||
"search_hf_models": "Search HuggingFace for models matching a query (e.g. 'qwen 8B', 'flux', 'llama-3 instruct'). Returns ranked repo IDs with sizes and download counts. Use for 'find a model', 'search huggingface for X', 'what models are there for Y'.",
|
||||
"list_cached_models": "List models already cached on disk locally or on a remote host. Accepts friendly Cookbook server names like ajax. Use for 'what models do I have', 'show cached models', 'is X downloaded', 'list my models'. Avoids re-downloading.",
|
||||
"list_serve_presets": "List saved Cookbook serve presets (templates with model+host+port+cmd). Always call this BEFORE serve_model when the user asks to launch a known model — they probably have a preset for it from the UI.",
|
||||
"list_cached_models": "List models already cached on disk locally or on a remote host. Accepts friendly Cookbook server names like workstation. Use for 'what models do I have', 'show cached models', 'is X downloaded', 'list my models'. Avoids re-downloading.",
|
||||
"list_serve_presets": "List saved Cookbook serve presets (templates with model+host+port+cmd). Call this BEFORE raw serve_model when the user asks to launch a known model manually.",
|
||||
"serve_preset": "Launch a saved Cookbook serve preset by name. Reuses the exact tmux command + host the user already saved. Use for 'run stable diffusion 3.5', 'serve vllm-qwen', 'start the inpaint model' — preset-name matches the user's UI labels.",
|
||||
"adopt_served_model": "Register an existing tmux model server (one started manually or outside the cookbook flow) into Cookbook tracking AND add it as a chat endpoint. Use when the user (or a previous turn) launched something via ssh+tmux and now wants it visible in the UI, stoppable via stop_served_model, and usable in the model picker.",
|
||||
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
|
||||
@@ -347,7 +351,7 @@ class ToolIndex:
|
||||
# whole email toolset and crowding out the relevant tools — the model then
|
||||
# believed it had only email tools and refused web/other tasks (#1707).
|
||||
frozenset({"email", "emails", "mail", "mails", "gmail", "googlemail", "message", "messages", "send", "reply", "replies", "inbox", "unread"}):
|
||||
{"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
|
||||
{"list_email_accounts", "list_emails", "read_email", "scan_email_unsubscribes", "unsubscribe_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
|
||||
frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
|
||||
{"manage_calendar"},
|
||||
# Detached background `bash` jobs (#!bg): check on / read output / kill.
|
||||
@@ -471,8 +475,10 @@ class ToolIndex:
|
||||
{"list_served_models", "stop_served_model"},
|
||||
# Cookbook serve / launch / preset / server selection
|
||||
frozenset({"serve", "launch", "spin up", "start the model", "run the model",
|
||||
"debug launch", "launch command", "drivers", "driver",
|
||||
"preset", "presets", "which server", "what servers",
|
||||
"gpu box", "cookbook server", "vllm", "on the server", "on the gpu"}):
|
||||
"gpu box", "cookbook server", "vllm", "sglang", "mlx", "llama.cpp",
|
||||
"on the server", "on the gpu"}):
|
||||
{"serve_preset", "serve_model", "list_serve_presets",
|
||||
"list_cookbook_servers", "list_cached_models"},
|
||||
# Cookbook downloads
|
||||
|
||||
@@ -250,6 +250,11 @@ _TOOL_NAME_MAP = {
|
||||
"write": "write_file",
|
||||
"write_file": "write_file",
|
||||
"save": "write_file",
|
||||
"apply_patch": "apply_patch",
|
||||
"patch": "apply_patch",
|
||||
"todowrite": "todowrite",
|
||||
"todo_write": "todowrite",
|
||||
"todo_update": "todowrite",
|
||||
"document": "update_document",
|
||||
"update_document": "update_document",
|
||||
"create_document": "create_document",
|
||||
|
||||
+87
-5
@@ -25,6 +25,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
|
||||
"read_file": ("path",),
|
||||
"write_file": ("path",),
|
||||
"edit_file": ("path",),
|
||||
"apply_patch": ("patch_text", "patchText", "patch"),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,6 +193,49 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "apply_patch",
|
||||
"description": "Apply a multi-file source-code patch to disk. Use for real project files in the workspace when several edits belong together. Patch must use *** Begin Patch / *** End Patch with Add File, Update File, or Delete File sections. Prefer this over bash redirects/heredocs/sed.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"patch_text": {
|
||||
"type": "string",
|
||||
"description": "Patch text beginning with *** Begin Patch and ending with *** End Patch"
|
||||
}
|
||||
},
|
||||
"required": ["patch_text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todowrite",
|
||||
"description": "Create and maintain a structured task list for the current coding session. Use during multi-step implementation/debug/refactor work and keep statuses current.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"description": "Current task list. Only one item should be in_progress.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "Task description"},
|
||||
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
|
||||
"priority": {"type": "string", "enum": ["low", "medium", "high"]}
|
||||
},
|
||||
"required": ["content", "status"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["todos"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -814,12 +858,12 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "serve_model",
|
||||
"description": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. If `host` is omitted, defaults to the cookbook's selected server (not localhost). For image/inpainting/diffusion models use the built-in command `python3 scripts/diffusion_server.py --model <repo> --port 8100` rather than inventing a custom diffusers API server. After launching, call list_served_models to check readiness/errors; if it reports a diagnosis with retry suggestions, retry via serve_model using the suggested adjusted cmd.",
|
||||
"description": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, MLX Image, or Diffusers. If `host` is omitted, defaults to the cookbook's selected server (not localhost). For MLX image models on Apple Silicon use `python3 scripts/mlx_image_server.py --model <repo> --port 8100`; for non-MLX image/inpainting/diffusion models use `python3 scripts/diffusion_server.py --model <repo> --port 8100`. Never serve image models with `mlx_lm.server`; that is only for text/chat MLX models. After launching, call list_served_models to check readiness/errors; if it reports a diagnosis with retry suggestions, retry via serve_model using the suggested adjusted cmd.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_id": {"type": "string", "description": "Model repo (e.g. 'Qwen/Qwen3-8B')"},
|
||||
"cmd": {"type": "string", "description": "Full serve command (e.g. 'vllm serve Qwen/Qwen3-8B --port 8000 --tp 2', 'python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B --port 30000', or for inpainting/image models: 'python3 scripts/diffusion_server.py --model diffusers/stable-diffusion-xl-1.0-inpainting-0.1 --port 8100')"},
|
||||
"cmd": {"type": "string", "description": "Full serve command (e.g. 'vllm serve <repo> --port 8000 --tp 2', 'python3 -m sglang.launch_server --model-path <repo> --port 30000', for MLX image models: 'python3 scripts/mlx_image_server.py --model <repo> --port 8100', or for non-MLX image models: 'python3 scripts/diffusion_server.py --model <repo> --port 8100')"},
|
||||
"host": {"type": "string", "description": "Target server — friendly NAME from list_cookbook_servers (e.g. 'gpu-box', 'workstation') or raw user@host. Omit to use the cookbook's selected default."},
|
||||
"local": {"type": "boolean", "description": "Force serve on THIS machine instead of the default remote server."},
|
||||
},
|
||||
@@ -913,7 +957,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_serve_presets",
|
||||
"description": "List saved Cookbook serve presets. Each preset is a launch template (name, model, host, port, tmux cmd) the user previously saved from the UI. Call this BEFORE serve_model when the user asks to launch a model by name — there's almost always a working preset for it.",
|
||||
"description": "List saved Cookbook serve presets. Each preset is a launch template (name, model, host, port, tmux cmd) the user previously saved from the UI. Call this BEFORE raw serve_model when the user asks to launch a model by name manually.",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
},
|
||||
@@ -954,11 +998,11 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_cached_models",
|
||||
"description": "List models already cached on disk locally or on a remote server. `host` accepts friendly Cookbook server names from list_cookbook_servers (for example ajax) or raw user@host. Also reports completed Cookbook download tasks when the filesystem cache scan cannot locate the HF cache path.",
|
||||
"description": "List models already cached on disk locally or on a remote server. `host` accepts friendly Cookbook server names from list_cookbook_servers (for example workstation) or raw user@host. Also reports completed Cookbook download tasks when the filesystem cache scan cannot locate the HF cache path.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Friendly Cookbook server name (e.g. 'ajax', 'gpu-box') or raw remote host (e.g. 'user@gpu-box'). Omit for local."},
|
||||
"host": {"type": "string", "description": "Friendly Cookbook server name (e.g. 'workstation', 'gpu-box') or raw remote host (e.g. 'user@gpu-box'). Omit for local."},
|
||||
"model_dir": {"type": "string", "description": "Comma-separated additional model directories to scan beyond ~/.cache/huggingface/hub"},
|
||||
"ssh_port": {"type": "string", "description": "SSH port for remote host (default 22)"},
|
||||
"platform": {"type": "string", "enum": ["linux", "windows"], "description": "Remote platform"}
|
||||
@@ -1114,6 +1158,40 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "scan_email_unsubscribes",
|
||||
"description": "Scan recent email headers for likely spam/newsletter unsubscribe candidates. Does not unsubscribe anything. Review candidates with the user before acting; mailto methods can be executed with unsubscribe_email, web URL methods require browser/web tools after approval.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"folder": {"type": "string", "description": "IMAP folder to scan (default: INBOX)"},
|
||||
"limit": {"type": "integer", "description": "Maximum candidates to return (default: 25)"},
|
||||
"max_scan": {"type": "integer", "description": "How many newest emails to inspect (default: 150)"},
|
||||
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "unsubscribe_email",
|
||||
"description": "Execute one approved unsubscribe action for an email UID. Safe mailto List-Unsubscribe methods are sent/staged. Web URL methods return a requires-browser instruction and exact URL; use browser/web tools only after user approval.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {"type": "string", "description": "Email UID from scan_email_unsubscribes/list_emails"},
|
||||
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)"},
|
||||
"method_index": {"type": "integer", "description": "Method index from scan_email_unsubscribes (default: 0)"},
|
||||
"allow_web": {"type": "boolean", "description": "Return browser/web instructions when selected method is URL"},
|
||||
"account": {"type": "string", "description": "Optional account name/email/id from list_email_accounts"},
|
||||
},
|
||||
"required": ["uid"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1367,6 +1445,10 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "edit_file":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "apply_patch":
|
||||
content = args.get("patch_text") or args.get("patchText") or args.get("patch") or ""
|
||||
elif tool_type == "todowrite":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "create_document":
|
||||
parts = [args.get("title", "Untitled")]
|
||||
if args.get("language"):
|
||||
|
||||
@@ -19,6 +19,8 @@ BUILTIN_EMAIL_TOOLS = frozenset({
|
||||
"list_emails",
|
||||
"read_email",
|
||||
"search_emails",
|
||||
"scan_email_unsubscribes",
|
||||
"unsubscribe_email",
|
||||
"send_email",
|
||||
"reply_to_email",
|
||||
"draft_email",
|
||||
@@ -44,6 +46,7 @@ NON_ADMIN_BLOCKED_TOOLS = BUILTIN_EMAIL_TOOLS | {
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"grep",
|
||||
"glob",
|
||||
"ls",
|
||||
@@ -110,6 +113,7 @@ PLAN_MODE_READONLY_TOOLS = {
|
||||
# classified — see the plan-mode partition test in
|
||||
# tests/test_email_registry_sync.py.
|
||||
"search_emails",
|
||||
"scan_email_unsubscribes",
|
||||
"list_served_models",
|
||||
"list_downloads",
|
||||
"list_cached_models",
|
||||
@@ -136,14 +140,15 @@ PLAN_MODE_READONLY_TOOLS = {
|
||||
# here — read-only tools are covered by the allowlist. Keep in sync when adding
|
||||
# new mutating tools.
|
||||
_PLAN_MODE_KNOWN_MUTATORS = {
|
||||
"write_file", "create_document", "edit_document", "update_document",
|
||||
"write_file", "edit_file", "apply_patch", "todowrite",
|
||||
"create_document", "edit_document", "update_document",
|
||||
"suggest_document", "manage_documents", "create_session", "manage_session",
|
||||
"send_to_session", "pipeline", "manage_memory", "manage_skills",
|
||||
"manage_tasks", "manage_notes", "manage_endpoints", "manage_mcp",
|
||||
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
|
||||
"manage_calendar", "api_call", "app_api", "ui_control",
|
||||
"send_email", "reply_to_email", "bulk_email", "delete_email",
|
||||
"archive_email", "mark_email_read",
|
||||
"archive_email", "mark_email_read", "unsubscribe_email",
|
||||
# The draft tools create documents and download_attachment writes to
|
||||
# disk — mutating. They have no native schemas (yet), so without these
|
||||
# static entries plan-mode safety for their bare fence tags would depend
|
||||
|
||||
+186
-1
@@ -33,6 +33,57 @@ def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple
|
||||
return remote, sport
|
||||
|
||||
|
||||
def _cookbook_label_key(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", str(value or "").lower())
|
||||
|
||||
|
||||
def _cookbook_is_exact_repo_id(value: Any) -> bool:
|
||||
return bool(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", str(value or "").strip()))
|
||||
|
||||
|
||||
def _cookbook_match_saved_preset(query: str, presets: List[Any], host: str = "") -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a user-facing model label to a saved serve preset.
|
||||
|
||||
The launch agent should be callable directly. If the model says
|
||||
`repo_id="Qwen3.6-27B-AEON"` because the user used the short UI label, do
|
||||
not force it through `list_serve_presets`; match the saved preset inside
|
||||
the autopilot and let `do_serve_preset` reuse the known-good command.
|
||||
"""
|
||||
q = _cookbook_label_key(query)
|
||||
if not q:
|
||||
return None
|
||||
host = str(host or "")
|
||||
exact_repo = _cookbook_is_exact_repo_id(query)
|
||||
candidates: List[tuple[int, Dict[str, Any]]] = []
|
||||
for p in presets or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
name = str(p.get("name") or "")
|
||||
model = str(p.get("model") or p.get("modelId") or "")
|
||||
phost = str(p.get("host") or p.get("remoteHost") or "")
|
||||
haystacks = [_cookbook_label_key(name), _cookbook_label_key(model)]
|
||||
if exact_repo:
|
||||
# If the user gave a real HF repo, only reuse a preset that names
|
||||
# that exact repo/label. A substring match here is dangerous:
|
||||
# cyankiwi/Qwen3.5-122B-A10B-AWQ-8bit must not launch the saved
|
||||
# generic Qwen/Qwen3.5-122B-A10B preset.
|
||||
if not any(h and q == h for h in haystacks):
|
||||
continue
|
||||
else:
|
||||
if not any(h and (q == h or q in h or h in q) for h in haystacks):
|
||||
continue
|
||||
score = 10
|
||||
if host and phost == host:
|
||||
score += 20
|
||||
if q in haystacks:
|
||||
score += 10
|
||||
candidates.append((score, p))
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda item: item[0], reverse=True)
|
||||
return candidates[0][1]
|
||||
|
||||
|
||||
async def _cookbook_servers() -> Dict[str, Any]:
|
||||
"""Return the cookbook's configured servers + the currently-selected
|
||||
default host. Shape: {default_host, hosts: [{host, platform, env, envPath}]}.
|
||||
@@ -200,7 +251,7 @@ async def _ensure_served_endpoint(
|
||||
port = _infer_serve_port(cmd)
|
||||
base_url = f"http://{endpoint_host}:{port}/v1"
|
||||
short_name = model.split("/")[-1] if "/" in model else model
|
||||
is_image = "diffusion_server.py" in (cmd or "")
|
||||
is_image = "diffusion_server.py" in (cmd or "") or "mlx_image_server.py" in (cmd or "")
|
||||
payload = {
|
||||
"name": short_name if not is_image else f"{short_name} (image)",
|
||||
"base_url": base_url,
|
||||
@@ -315,6 +366,7 @@ async def _cookbook_register_task(
|
||||
_MODEL_PROCESS_PATTERNS = [
|
||||
("vLLM", ["vllm.entrypoints", "vllm serve", "/vllm/", "vllm-openai"]),
|
||||
("SGLang", ["sglang.launch_server", "sglang/launch_server"]),
|
||||
("MLX Image", ["mlx_image_server.py", "mflux-generate-qwen", "mflux-generate"]),
|
||||
("MLX", ["mlx_lm.server", "mlx-lm"]),
|
||||
("llama.cpp", ["llama-server", "llama_cpp_server", "llamacppserver"]),
|
||||
("Ollama", ["ollama serve", "ollama runner", "/ollama "]),
|
||||
@@ -358,6 +410,139 @@ def _cookbook_apply_retry_suggestion(cmd: str, suggestion: Dict[str, Any]) -> st
|
||||
return cmd
|
||||
|
||||
|
||||
def _cookbook_engine_from_model_info(repo_id: str, info: Optional[Dict[str, Any]], host_meta: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Choose a conservative serve engine from repo metadata and target host.
|
||||
|
||||
This is intentionally heuristic: the model card / file list tells us the
|
||||
official repo and likely format, while the actual serve command still goes
|
||||
through Cookbook and is diagnosed/retried after launch.
|
||||
"""
|
||||
rid = (repo_id or "").lower()
|
||||
info = info or {}
|
||||
host_meta = host_meta or {}
|
||||
platform = (host_meta.get("platform") or "").lower()
|
||||
tags = {str(t).lower() for t in (info.get("tags") or []) if t is not None}
|
||||
siblings = [str(s).lower() for s in (info.get("siblings") or []) if s is not None]
|
||||
files = " ".join(siblings)
|
||||
is_image = (
|
||||
"diffusers" in tags
|
||||
or "text-to-image" in tags
|
||||
or "image-to-image" in tags
|
||||
or any(k in rid for k in ("qwen-image", "z-image", "flux", "stable-diffusion", "sdxl", "hidream", "boogu", "krea-2"))
|
||||
)
|
||||
is_mlx_image = is_image and ("mlx" in tags or "mlx" in rid or "mlx-community/" in rid)
|
||||
if is_mlx_image:
|
||||
return "mlx_image"
|
||||
if is_image:
|
||||
return "diffusers"
|
||||
if "mlx" in tags or "mlx" in rid or "mlx-community/" in rid or platform in {"macos", "darwin"}:
|
||||
return "mlx"
|
||||
if "gguf" in tags or "gguf" in rid or ".gguf" in files:
|
||||
return "llama.cpp"
|
||||
if "sglang" in tags or "sglang" in rid:
|
||||
return "sglang"
|
||||
if any(q in rid or q in files or q in tags for q in ("awq", "fp8", "gptq", "bnb", "bitsandbytes")):
|
||||
return "vllm"
|
||||
return "vllm"
|
||||
|
||||
|
||||
def _cookbook_default_launch_cmd(repo_id: str, engine: str, *, port: int = 8000, info: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build a simple first-attempt command for a selected engine."""
|
||||
engine = (engine or "vllm").lower()
|
||||
port = int(port or 8000)
|
||||
if engine in {"mlx", "mlx-lm", "mlx_lm"}:
|
||||
return f"python3 -m mlx_lm.server --model {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"mlx_image", "mlx-image", "mflux"}:
|
||||
return f"python3 scripts/mlx_image_server.py --model {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"diffusers", "diffusion", "image"}:
|
||||
return f"python3 scripts/diffusion_server.py --model {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"sglang", "sgl"}:
|
||||
return f"python3 -m sglang.launch_server --model-path {repo_id} --host 0.0.0.0 --port {port}"
|
||||
if engine in {"llama.cpp", "llamacpp", "llama"}:
|
||||
siblings = [str(s) for s in ((info or {}).get("siblings") or []) if str(s).lower().endswith(".gguf")]
|
||||
if siblings:
|
||||
# llama-server accepts HF repo + filename separately on recent builds.
|
||||
return f"llama-server -hf {repo_id} -hfr {siblings[0]} --host 0.0.0.0 --port {port}"
|
||||
return f"llama-server -hf {repo_id} --host 0.0.0.0 --port {port}"
|
||||
return f"vllm serve {repo_id} --host 0.0.0.0 --port {port}"
|
||||
|
||||
|
||||
async def _cookbook_hf_model_info(repo_id: str) -> Dict[str, Any]:
|
||||
"""Fetch lightweight official Hugging Face metadata for launch planning.
|
||||
|
||||
Uses the public HF API directly so this works even when huggingface_hub is
|
||||
not installed in Odysseus. Failures return a structured warning rather than
|
||||
blocking launch; cached/private/offline models can still be served.
|
||||
"""
|
||||
import httpx
|
||||
from routes.cookbook_helpers import load_stored_hf_token
|
||||
|
||||
repo_id = (repo_id or "").strip().strip("/")
|
||||
if not repo_id:
|
||||
return {"error": "repo_id is required"}
|
||||
headers: Dict[str, str] = {"Accept": "application/json"}
|
||||
token = load_stored_hf_token()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
url = f"https://huggingface.co/api/models/{repo_id}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://huggingface.co/{repo_id}",
|
||||
"error": f"HF metadata lookup returned HTTP {resp.status_code}",
|
||||
}
|
||||
data = resp.json() if resp.content else {}
|
||||
except Exception as e:
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://huggingface.co/{repo_id}",
|
||||
"error": f"HF metadata lookup failed: {e}",
|
||||
}
|
||||
siblings = []
|
||||
for s in data.get("siblings") or []:
|
||||
if isinstance(s, dict) and s.get("rfilename"):
|
||||
siblings.append(s["rfilename"])
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"url": f"https://huggingface.co/{repo_id}",
|
||||
"pipeline_tag": data.get("pipeline_tag") or "",
|
||||
"library_name": data.get("library_name") or "",
|
||||
"tags": data.get("tags") or [],
|
||||
"sha": data.get("sha") or "",
|
||||
"private": bool(data.get("private")),
|
||||
"gated": data.get("gated"),
|
||||
"siblings": siblings[:500],
|
||||
"cardData": data.get("cardData") or {},
|
||||
}
|
||||
|
||||
|
||||
def _cookbook_host_meta(host: str, servers: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for item in servers.get("hosts") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if (item.get("host") or "") == (host or ""):
|
||||
return item
|
||||
return {}
|
||||
|
||||
|
||||
def _cookbook_find_task(tasks: List[Dict[str, Any]], session_id: str) -> Optional[Dict[str, Any]]:
|
||||
for task in tasks or []:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
if task.get("session_id") == session_id or task.get("sessionId") == session_id or task.get("id") == session_id:
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def _cookbook_phase(task: Optional[Dict[str, Any]]) -> str:
|
||||
if not task:
|
||||
return "unknown"
|
||||
return str(task.get("phase") or task.get("status") or "unknown").lower()
|
||||
|
||||
|
||||
def _scan_running_model_processes() -> List[Dict[str, Any]]:
|
||||
"""Scan /proc for running model server processes. Linux-only; returns
|
||||
[] on other platforms or if /proc isn't accessible. Each match returns
|
||||
|
||||
+29
-2
@@ -32,8 +32,35 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(f"{_INTERNAL_BASE}/api/gallery/{action}", json=payload)
|
||||
data = resp.json()
|
||||
if data.get("success") or data.get("id"):
|
||||
return {"output": f"Image edited ({action}). New image ID: {data.get('id', '?')}", "exit_code": 0}
|
||||
new_id = data.get("id") or data.get("image_id")
|
||||
if data.get("success") or new_id:
|
||||
result = {
|
||||
"output": f"Image edited ({action}). New image ID: {new_id or '?'}",
|
||||
"exit_code": 0,
|
||||
}
|
||||
if new_id:
|
||||
result["image_id"] = new_id
|
||||
try:
|
||||
from src.database import GalleryImage, SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.id == new_id)
|
||||
if owner:
|
||||
q = q.filter(GalleryImage.owner == owner)
|
||||
img = q.first()
|
||||
if img and img.filename:
|
||||
result.update({
|
||||
"image_url": f"/api/generated-image/{img.filename}",
|
||||
"image_prompt": img.prompt or args.get("prompt") or action,
|
||||
"image_model": img.model or "edit_image",
|
||||
"image_size": img.size or "",
|
||||
"image_quality": img.quality or "",
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
return {"error": data.get("error", f"{action} failed"), "exit_code": 1}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
+38
-15
@@ -280,7 +280,30 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
if not args.get("action") and any(args.get(k) is not None for k in ("task", "description", "schedule", "time", "day_of_week")):
|
||||
args["action"] = "create"
|
||||
if args.get("task") and not args.get("name"):
|
||||
args["name"] = args["task"]
|
||||
if args.get("task") and not args.get("prompt"):
|
||||
args["prompt"] = args["task"]
|
||||
action = args.get("action", "list")
|
||||
if args.get("description") and not args.get("prompt"):
|
||||
args["prompt"] = args["description"]
|
||||
if args.get("time") and not args.get("scheduled_time"):
|
||||
args["scheduled_time"] = args["time"]
|
||||
if args.get("day_of_week") is not None and args.get("scheduled_day") is None:
|
||||
day = str(args.get("day_of_week")).strip().lower()
|
||||
days = {
|
||||
"monday": 0, "mon": 0,
|
||||
"tuesday": 1, "tue": 1, "tues": 1,
|
||||
"wednesday": 2, "wed": 2,
|
||||
"thursday": 3, "thu": 3, "thur": 3, "thurs": 3,
|
||||
"friday": 4, "fri": 4,
|
||||
"saturday": 5, "sat": 5,
|
||||
"sunday": 6, "sun": 6,
|
||||
}
|
||||
if day in days:
|
||||
args["scheduled_day"] = days[day]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if action == "list":
|
||||
@@ -288,21 +311,21 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if owner:
|
||||
q = q.filter(ScheduledTask.owner == owner)
|
||||
tasks = q.order_by(ScheduledTask.created_at.desc()).all()
|
||||
task_list = []
|
||||
for t in tasks:
|
||||
task_list.append({
|
||||
"id": t.id, "name": t.name, "status": t.status,
|
||||
"task_type": t.task_type or "llm",
|
||||
"action": t.action,
|
||||
"trigger_type": t.trigger_type or "schedule",
|
||||
"schedule": t.schedule,
|
||||
"trigger_event": t.trigger_event,
|
||||
"trigger_count": t.trigger_count,
|
||||
"next_run": t.next_run.isoformat() + "Z" if t.next_run else None,
|
||||
"last_run": t.last_run.isoformat() + "Z" if t.last_run else None,
|
||||
"run_count": t.run_count or 0,
|
||||
})
|
||||
return {"response": f"Found {len(task_list)} tasks", "tasks": task_list, "exit_code": 0}
|
||||
if not tasks:
|
||||
return {"response": "No scheduled tasks found.", "exit_code": 0}
|
||||
|
||||
lines = [f"Found {len(tasks)} tasks:"]
|
||||
for idx, t in enumerate(tasks, 1):
|
||||
bits = [t.status or "unknown"]
|
||||
if t.schedule:
|
||||
bits.append(str(t.schedule))
|
||||
if t.scheduled_time:
|
||||
bits.append(str(t.scheduled_time))
|
||||
if t.next_run:
|
||||
bits.append(f"next {t.next_run.isoformat()}Z")
|
||||
detail = ", ".join(bits)
|
||||
lines.append(f"{idx}. {t.name} ({t.id}) — {detail}")
|
||||
return {"response": "\n".join(lines), "exit_code": 0}
|
||||
|
||||
elif action == "create":
|
||||
task_type = args.get("task_type", "llm")
|
||||
|
||||
+364
-72
@@ -6,29 +6,29 @@ import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js';
|
||||
import modelsModule from './js/models.js?v=20260715startupcalm2';
|
||||
import ragModule from './js/rag.js';
|
||||
import presetsModule from './js/presets.js';
|
||||
import searchModule from './js/search.js';
|
||||
import chatModule from './js/chat.js';
|
||||
import compareModule from './js/compare/index.js';
|
||||
import documentModule from './js/document.js';
|
||||
import chatModule from './js/chat.js?v=20260722ctxheader4';
|
||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js';
|
||||
import sessionModule from './js/sessions.js';
|
||||
import memoryModule from './js/memory.js';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
||||
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
|
||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js';
|
||||
import tasksModule from './js/tasks.js?v=20260630tasksactivity';
|
||||
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||
import calendarModule from './js/calendar.js';
|
||||
import notesModule from './js/notes.js';
|
||||
import adminModule from './js/admin.js';
|
||||
import settingsModule from './js/settings.js';
|
||||
import adminModule from './js/admin.js?v=20260716openrouter3';
|
||||
import settingsModule from './js/settings.js?v=20260722emailfastindex1';
|
||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||
import './js/modalManager.js';
|
||||
import './js/modalManager.js?v=20260723compareicon2';
|
||||
// Desktop window tiling — drag a modal near an edge/corner to snap.
|
||||
import './js/tileManager.js';
|
||||
import themeModule from './js/theme.js';
|
||||
@@ -43,7 +43,7 @@ import * as researchPanelModule from './js/research/panel.js?v=20260630researcht
|
||||
import ttsModule from './js/tts-ai.js';
|
||||
import spinnerModule from './js/spinner.js';
|
||||
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
|
||||
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js';
|
||||
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260715startupclean';
|
||||
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
@@ -204,12 +204,22 @@ const el = uiModule.el;
|
||||
// changes take effect immediately (previously cached once at page load and
|
||||
// went stale when the user changed their default model).
|
||||
let _defaultChat = null;
|
||||
try {
|
||||
const cachedDefaultChat = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
|
||||
if (cachedDefaultChat && cachedDefaultChat.endpoint_url && cachedDefaultChat.model) {
|
||||
_defaultChat = cachedDefaultChat;
|
||||
window.__odysseusDefaultChat = cachedDefaultChat;
|
||||
}
|
||||
} catch (_) {}
|
||||
async function _refreshDefaultChat() {
|
||||
try {
|
||||
const d = await (await fetch('/api/default-chat')).json();
|
||||
if (d && d.endpoint_url && d.model) {
|
||||
_defaultChat = d;
|
||||
try { window.__odysseusDefaultChat = d; } catch (_) {}
|
||||
try {
|
||||
window.__odysseusDefaultChat = d;
|
||||
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(d));
|
||||
} catch (_) {}
|
||||
return d;
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -224,7 +234,7 @@ async function _createDirectChatFromPreferredModel() {
|
||||
|
||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||
if (pending && pending.url && pending.modelId && pending.endpointId) {
|
||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId, { source: pending.source || 'manual' });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -238,7 +248,7 @@ async function _createDirectChatFromPreferredModel() {
|
||||
|
||||
const dc = await _refreshDefaultChat();
|
||||
if (dc) {
|
||||
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -252,50 +262,6 @@ async function _createDirectChatFromPreferredModel() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function _hasUsableChatModel() {
|
||||
try {
|
||||
const pending = sessionModule?.getPendingChat?.();
|
||||
if (pending && pending.url && pending.modelId) return true;
|
||||
} catch (_) {}
|
||||
try {
|
||||
const current = sessionModule?.getSessions?.()
|
||||
?.find(s => s.id === sessionModule?.getCurrentSessionId?.());
|
||||
if (current && current.endpoint_url && current.model) return true;
|
||||
} catch (_) {}
|
||||
const dc = await _refreshDefaultChat();
|
||||
if (dc && dc.endpoint_url && dc.model) return true;
|
||||
try {
|
||||
const items = window.modelsModule?.getCachedItems?.() || [];
|
||||
if (items.some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length))) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/models?background=false`, { credentials: 'same-origin' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return (data.items || []).some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _syncWelcomeModelHint() {
|
||||
const tip = document.getElementById('welcome-tip');
|
||||
const sub = document.getElementById('welcome-sub');
|
||||
if (!tip && !sub) return;
|
||||
const hasModel = await _hasUsableChatModel();
|
||||
if (hasModel) {
|
||||
if (sub && !sub.dataset.researchOrigText) sub.textContent = 'New chat ready.';
|
||||
if (tip) tip.textContent = 'Pick a model if you want, or just type.';
|
||||
} else {
|
||||
if (sub && !sub.dataset.researchOrigText) {
|
||||
sub.innerHTML = 'Welcome, <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">type /setup</span> to get started.';
|
||||
}
|
||||
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EVENT LISTENERS INITIALIZATION
|
||||
// ============================================
|
||||
@@ -522,6 +488,20 @@ function initializeEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
// Export menu: Compact current chat context
|
||||
const exportCompactBtn = el('export-compact-btn');
|
||||
if (exportCompactBtn) {
|
||||
exportCompactBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (window.compactCurrentChatContext) {
|
||||
await window.compactCurrentChatContext();
|
||||
} else {
|
||||
uiModule.showError('Compact action is not ready yet');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export: PDF
|
||||
const exportPdfBtn = el('export-pdf-btn');
|
||||
if (exportPdfBtn) {
|
||||
@@ -574,6 +554,18 @@ function initializeEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
// Export menu: Delete current chat
|
||||
const exportDeleteBtn = el('export-delete-btn');
|
||||
if (exportDeleteBtn) {
|
||||
exportDeleteBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (sessionModule?.deleteCurrentSessionFromTopMenu) {
|
||||
await sessionModule.deleteCurrentSessionFromTopMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Rename session from top bar
|
||||
const exportRenameBtn = el('export-rename-btn');
|
||||
if (exportRenameBtn) {
|
||||
@@ -1247,6 +1239,7 @@ function initializeEventListeners() {
|
||||
if (libraryNewDocBtn) {
|
||||
libraryNewDocBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (libraryNewDocBtn.dataset.docNewWired === '1') return;
|
||||
try {
|
||||
if (documentModule && documentModule.newDocument) await documentModule.newDocument();
|
||||
} catch (err) {
|
||||
@@ -1746,6 +1739,42 @@ function initializeEventListeners() {
|
||||
uiModule.showToast(`${label} ${active ? 'on' : 'off'}`, 1800);
|
||||
}
|
||||
|
||||
function syncPlanToggle(active) {
|
||||
const btn = el('plan-toggle-btn');
|
||||
const chk = el('plan-toggle');
|
||||
const status = el('plan-mode-status');
|
||||
const statusToggle = el('plan-mode-status-toggle');
|
||||
if (chk) chk.checked = !!active;
|
||||
document.body.classList.toggle('plan-mode-active', !!active);
|
||||
if (status) status.hidden = !active;
|
||||
if (statusToggle) {
|
||||
statusToggle.setAttribute('aria-pressed', String(!!active));
|
||||
statusToggle.setAttribute('aria-label', active ? 'Turn off plan mode' : 'Turn on plan mode');
|
||||
}
|
||||
if (btn) {
|
||||
btn.classList.toggle('active', !!active);
|
||||
btn.setAttribute('aria-pressed', String(!!active));
|
||||
btn.title = active
|
||||
? 'Plan mode on - next message proposes a plan only'
|
||||
: 'Plan mode';
|
||||
}
|
||||
}
|
||||
|
||||
function setPlanMode(active, options = {}) {
|
||||
const on = !!active;
|
||||
const st = loadToggleState();
|
||||
st.plan_mode = on;
|
||||
saveToggleState(st);
|
||||
syncPlanToggle(on);
|
||||
if (on) {
|
||||
const resChk = el('research-toggle');
|
||||
if (resChk && resChk.checked) _syncResearchIndicator(false);
|
||||
}
|
||||
if (!options.silent && uiModule?.showToast) {
|
||||
uiModule.showToast(on ? 'Plan mode on' : 'Plan mode off', 1600);
|
||||
}
|
||||
}
|
||||
|
||||
function applyModeToToggles(mode) {
|
||||
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
|
||||
const btn = el(btnId);
|
||||
@@ -1806,6 +1835,60 @@ function initializeEventListeners() {
|
||||
setMode(currentMode);
|
||||
})();
|
||||
|
||||
(function initPlanToggle() {
|
||||
const btn = el('plan-toggle-btn');
|
||||
const state = loadToggleState();
|
||||
syncPlanToggle(!!state.plan_mode);
|
||||
window.__odysseusSetPlanMode = (active) => setPlanMode(active, { silent: true });
|
||||
const statusToggle = el('plan-mode-status-toggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
const st = loadToggleState();
|
||||
setPlanMode(!st.plan_mode);
|
||||
});
|
||||
}
|
||||
if (statusToggle) {
|
||||
statusToggle.addEventListener('click', () => setPlanMode(false));
|
||||
}
|
||||
const msgInput = el('message');
|
||||
if (msgInput && !msgInput._odysseusPlanTabToggle) {
|
||||
msgInput._odysseusPlanTabToggle = true;
|
||||
msgInput.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Tab' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const st = loadToggleState();
|
||||
setPlanMode(!st.plan_mode);
|
||||
});
|
||||
}
|
||||
const chatBar = document.querySelector('.chat-input-bar');
|
||||
if (chatBar && !chatBar._odysseusPlanSwipeToggle) {
|
||||
chatBar._odysseusPlanSwipeToggle = true;
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
let touchStartAt = 0;
|
||||
chatBar.addEventListener('touchstart', (e) => {
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
touchStartX = t.clientX;
|
||||
touchStartY = t.clientY;
|
||||
touchStartAt = Date.now();
|
||||
}, { passive: true });
|
||||
chatBar.addEventListener('touchend', (e) => {
|
||||
if (!touchStartAt || !e.changedTouches || e.changedTouches.length !== 1) return;
|
||||
if (!window.matchMedia || !window.matchMedia('(max-width: 768px)').matches) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - touchStartX;
|
||||
const dy = t.clientY - touchStartY;
|
||||
const dt = Date.now() - touchStartAt;
|
||||
touchStartAt = 0;
|
||||
if (dt > 700 || Math.abs(dx) < 56 || Math.abs(dy) > 28 || Math.abs(dx) < Math.abs(dy) * 2) return;
|
||||
const st = loadToggleState();
|
||||
setPlanMode(!st.plan_mode);
|
||||
}, { passive: true });
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Tool splash explainer messages (shown first 2 times per tool) ──
|
||||
const SPLASH_COUNT_KEY = 'odysseus-tool-splash-counts';
|
||||
const SPLASH_MAX = 2;
|
||||
@@ -2305,18 +2388,40 @@ function initializeEventListeners() {
|
||||
const textarea = el('message');
|
||||
const inputBottom = document.querySelector('.chat-input-bottom');
|
||||
const _isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
let _placeholderHintOn = false;
|
||||
|
||||
function setComposerPlaceholder(width) {
|
||||
if (!textarea) return;
|
||||
if (_isMobile && _placeholderHintOn) {
|
||||
textarea.setAttribute('placeholder', 'Swipe to toggle plan');
|
||||
return;
|
||||
}
|
||||
textarea.setAttribute('placeholder', width < PLACEHOLDER_COMPACT_WIDTH ? 'Message...' : 'Message Odysseus...');
|
||||
}
|
||||
|
||||
if (_isMobile && textarea && !textarea._odysseusPlanPlaceholderHint) {
|
||||
textarea._odysseusPlanPlaceholderHint = true;
|
||||
setInterval(() => {
|
||||
_placeholderHintOn = !_placeholderHintOn;
|
||||
setComposerPlaceholder(inputTop.clientWidth || window.innerWidth || 0);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function checkPickerOverflow() {
|
||||
// Skip responsive collapse on mobile — keyboard open/close causes flicker
|
||||
if (_isMobile) return;
|
||||
const w = inputTop.clientWidth;
|
||||
const w = inputTop.clientWidth || window.innerWidth || 0;
|
||||
const hasText = !!(textarea && textarea.value && textarea.value.trim());
|
||||
if (_isMobile) {
|
||||
// Mobile has much less horizontal room: any typed text should get
|
||||
// the full composer row, matching plan-mode's collision behavior.
|
||||
pickerWrap.classList.toggle('picker-auto-hidden', hasText);
|
||||
setComposerPlaceholder(w);
|
||||
return;
|
||||
}
|
||||
// Hide model picker
|
||||
pickerWrap.classList.toggle('picker-auto-hidden', w < PICKER_HIDE_WIDTH);
|
||||
// Keep a prompt inside the composer even when the picker crowds the row.
|
||||
// A blank placeholder makes the mobile/compact empty state feel broken.
|
||||
if (textarea) {
|
||||
textarea.setAttribute('placeholder', w < PLACEHOLDER_COMPACT_WIDTH ? 'Message...' : 'Message Odysseus...');
|
||||
}
|
||||
setComposerPlaceholder(w);
|
||||
// Hide entire bottom toolbar (tools, mode toggle) — only send button remains
|
||||
if (inputBottom) {
|
||||
inputBottom.classList.toggle('toolbar-auto-hidden', w < TOOLBAR_HIDE_WIDTH);
|
||||
@@ -2482,6 +2587,11 @@ function initializeEventListeners() {
|
||||
incognitoBtn.title = chk.checked ? 'Disable Nobody mode' : 'Enable Nobody mode — no memory, no history saved';
|
||||
const welcomeName = document.querySelector('.welcome-name');
|
||||
if (chk.checked) {
|
||||
try {
|
||||
if (sessionModule && sessionModule.setCurrentSessionId) sessionModule.setCurrentSessionId(null);
|
||||
const box = el('chat-history');
|
||||
if (box) box.innerHTML = '';
|
||||
} catch (_) {}
|
||||
incognitoBtn.innerHTML = INCOGNITO_EYE_CLOSED + '<span class="incognito-label">Nobody</span>';
|
||||
if (welcomeName) {
|
||||
welcomeName.dataset.originalHtml = welcomeName.innerHTML;
|
||||
@@ -2607,7 +2717,6 @@ function initializeEventListeners() {
|
||||
'sidebar-search': '#sidebar-search-btn',
|
||||
'sessions-section': '#sessions-section',
|
||||
'email-section': '#email-section',
|
||||
'models-section': '#models-section',
|
||||
'tools-section': '#tools-section',
|
||||
// Per-tool visibility — fine-grained control over which entries show
|
||||
// inside the Tools section in the sidebar.
|
||||
@@ -2639,7 +2748,7 @@ function initializeEventListeners() {
|
||||
};
|
||||
|
||||
// Keys hidden by default on first run (no localStorage yet)
|
||||
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
// Keys that need admin to toggle off (reserved for future use)
|
||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||
@@ -3160,7 +3269,7 @@ function initializeEventListeners() {
|
||||
['.memory-tabs', '.memory-tab'],
|
||||
['.admin-tabs', '.admin-tab'],
|
||||
];
|
||||
const _IGNORE = 'input, textarea, select, [contenteditable="true"], .preset-range, ' +
|
||||
const _IGNORE = '.chat-input-bar, input, textarea, select, [contenteditable="true"], .preset-range, ' +
|
||||
'.note-cl-row, .minimized-dock-chip, canvas, .email-card-reader';
|
||||
let sx = 0, sy = 0, tracking = false;
|
||||
|
||||
@@ -3576,6 +3685,7 @@ function initializeEventListeners() {
|
||||
// INITIALIZATION ON PAGE LOAD
|
||||
// ============================================
|
||||
function startOdysseusApp() {
|
||||
tasksModule?.startNotificationPolling?.();
|
||||
if (window.__odysseusAppStarted) return;
|
||||
window.__odysseusAppStarted = true;
|
||||
const _bumpChatPriority = (ms = 10000) => {
|
||||
@@ -3787,6 +3897,7 @@ function startOdysseusApp() {
|
||||
return;
|
||||
}
|
||||
|
||||
chatRenderer.hideWelcomeScreen();
|
||||
return originalSubmit.call(chatModule, e);
|
||||
}
|
||||
|
||||
@@ -3797,6 +3908,86 @@ function startOdysseusApp() {
|
||||
const messageInput = el('message');
|
||||
const modelPickerWrap = document.getElementById('model-picker-wrap');
|
||||
|
||||
function _readComposerPromptHistory() {
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
if (!chatBox) return [];
|
||||
return Array.from(chatBox.querySelectorAll('.msg-user'))
|
||||
.reverse()
|
||||
.map(msg => {
|
||||
const body = msg.querySelector('.body');
|
||||
return msg.dataset?.raw || (body ? body.textContent : '') || '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (messageInput && !messageInput._odysseusPromptRecallCapture) {
|
||||
messageInput._odysseusPromptRecallCapture = true;
|
||||
let recallHistory = [];
|
||||
let recallIndex = -1;
|
||||
let lastRecalled = '';
|
||||
const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
|
||||
messageInput.addEventListener('input', () => {
|
||||
if (norm(messageInput.value) === norm(lastRecalled)) return;
|
||||
recallHistory = [];
|
||||
recallIndex = -1;
|
||||
lastRecalled = '';
|
||||
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
|
||||
}, true);
|
||||
messageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
|
||||
if (window._ghostAutocomplete?.isActive?.()) return;
|
||||
const fresh = _readComposerPromptHistory();
|
||||
const history = fresh.length ? fresh : recallHistory;
|
||||
if (!history.length) return;
|
||||
const current = norm(messageInput.value);
|
||||
let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
|
||||
if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
|
||||
if (current && currentIndex < 0) {
|
||||
const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
|
||||
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
|
||||
currentIndex = markedIndex;
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (currentIndex < 0) return;
|
||||
const nextIndex = currentIndex - 1;
|
||||
if (nextIndex < 0) {
|
||||
recallHistory = history;
|
||||
recallIndex = -1;
|
||||
lastRecalled = '';
|
||||
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
|
||||
messageInput.value = '';
|
||||
try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
|
||||
try { uiModule.autoResize(messageInput); } catch {}
|
||||
return;
|
||||
}
|
||||
const recalled = history[nextIndex];
|
||||
recallHistory = history;
|
||||
recallIndex = nextIndex;
|
||||
lastRecalled = recalled;
|
||||
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
|
||||
messageInput.value = recalled;
|
||||
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
|
||||
try { uiModule.autoResize(messageInput); } catch {}
|
||||
return;
|
||||
}
|
||||
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||
const recalled = history[nextIndex];
|
||||
if (!recalled) return;
|
||||
recallHistory = history;
|
||||
recallIndex = nextIndex;
|
||||
lastRecalled = recalled;
|
||||
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
|
||||
messageInput.value = recalled;
|
||||
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
|
||||
try { uiModule.autoResize(messageInput); } catch {}
|
||||
}, true);
|
||||
}
|
||||
|
||||
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
|
||||
const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
const _stopIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
|
||||
@@ -4006,12 +4197,17 @@ function startOdysseusApp() {
|
||||
// Toggle mic/send icon on input change + hide model picker after enough text
|
||||
if (messageInput) {
|
||||
const _debouncedUpdateIcon = uiModule.debounce(_updateSendBtnIcon, 50);
|
||||
const _MODEL_PICKER_HIDE_CHARS = 10;
|
||||
const _MODEL_PICKER_HIDE_CHARS = 23;
|
||||
const _syncModelPickerAutohide = () => {
|
||||
const hidePicker = (messageInput.value || '').replace(/\s/g, '').length >= _MODEL_PICKER_HIDE_CHARS;
|
||||
const compactMobile = _isMobileChatInput() && !!(messageInput.value || '').trim();
|
||||
const hidePicker = compactMobile || (messageInput.value || '').replace(/\s/g, '').length >= _MODEL_PICKER_HIDE_CHARS;
|
||||
if (modelPickerWrap) {
|
||||
modelPickerWrap.classList.toggle('model-picker-autohide', hidePicker);
|
||||
}
|
||||
const planStatus = el('plan-mode-status');
|
||||
if (planStatus) {
|
||||
planStatus.classList.toggle('plan-mode-status-autohide', hidePicker);
|
||||
}
|
||||
};
|
||||
window._syncModelPickerAutohide = _syncModelPickerAutohide;
|
||||
_syncModelPickerAutohide();
|
||||
@@ -4238,11 +4434,9 @@ function startOdysseusApp() {
|
||||
|
||||
// Non-critical startup work must not compete with first paint, chat send, or
|
||||
// chat switching. Panels load their own data when opened; these are only warmups.
|
||||
_syncWelcomeModelHint().catch(() => {});
|
||||
runNonCriticalStartup(() => {
|
||||
modelsModule.refreshModels(false).then(() => {
|
||||
try { sessionModule.updateModelPicker(); } catch (_) {}
|
||||
_syncWelcomeModelHint().catch(() => {});
|
||||
}).catch(() => {});
|
||||
}, 3500);
|
||||
runNonCriticalStartup(() => modelsModule.refreshProviders(), 6500);
|
||||
@@ -4253,6 +4447,104 @@ function startOdysseusApp() {
|
||||
voiceRecorderModule.init();
|
||||
if (censorModule) censorModule.init();
|
||||
|
||||
// ── Mobile pull-to-refresh for the active chat ──
|
||||
(function initMobileChatPullRefresh() {
|
||||
const historyEl = document.getElementById('chat-history');
|
||||
const container = document.getElementById('chat-container');
|
||||
if (!historyEl || !container || !('ontouchstart' in window || navigator.maxTouchPoints > 0)) return;
|
||||
|
||||
const THRESHOLD = 72;
|
||||
const MAX_PULL = 104;
|
||||
let startY = 0;
|
||||
let pullY = 0;
|
||||
let tracking = false;
|
||||
let refreshing = false;
|
||||
let spinner = null;
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'chat-pull-refresh';
|
||||
indicator.setAttribute('aria-hidden', 'true');
|
||||
indicator.innerHTML = '<div class="chat-pull-refresh-spinner"></div>';
|
||||
container.prepend(indicator);
|
||||
const spinnerMount = indicator.querySelector('.chat-pull-refresh-spinner');
|
||||
try {
|
||||
spinner = spinnerModule.createWhirlpool(18);
|
||||
spinnerMount.replaceChildren(spinner.element);
|
||||
} catch (_) {}
|
||||
|
||||
function setPull(px, active = false) {
|
||||
pullY = Math.max(0, Math.min(MAX_PULL, px));
|
||||
const pct = Math.min(1, pullY / THRESHOLD);
|
||||
indicator.style.setProperty('--pull-refresh-y', `${pullY}px`);
|
||||
indicator.style.setProperty('--pull-refresh-progress', `${pct}`);
|
||||
indicator.classList.toggle('is-visible', active || refreshing || pullY > 2);
|
||||
indicator.classList.toggle('is-ready', pct >= 1 && !refreshing);
|
||||
indicator.classList.toggle('is-refreshing', refreshing);
|
||||
}
|
||||
|
||||
async function runRefresh() {
|
||||
if (refreshing) return;
|
||||
if (_isForegroundChatBusy()) {
|
||||
setPull(0, false);
|
||||
return;
|
||||
}
|
||||
refreshing = true;
|
||||
setPull(THRESHOLD, true);
|
||||
const safetyTimer = setTimeout(() => {
|
||||
refreshing = false;
|
||||
setPull(0, false);
|
||||
}, 8000);
|
||||
try {
|
||||
const sid = sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId();
|
||||
if (sid && sessionModule.selectSession) {
|
||||
await sessionModule.selectSession(sid, { keepSidebar: true, showLoading: false, immediateLoading: true });
|
||||
} else if (sessionModule && sessionModule.loadSessions) {
|
||||
await sessionModule.loadSessions();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('pull refresh failed:', err);
|
||||
} finally {
|
||||
clearTimeout(safetyTimer);
|
||||
refreshing = false;
|
||||
setPull(0, false);
|
||||
}
|
||||
}
|
||||
|
||||
historyEl.addEventListener('touchstart', (e) => {
|
||||
if (refreshing || window.innerWidth > 768) return;
|
||||
if (document.querySelector('.modal:not(.hidden)')) return;
|
||||
if (historyEl.scrollTop > 0) return;
|
||||
if (e.target && e.target.closest && e.target.closest('.chat-input-bar, textarea, input, button, select, a')) return;
|
||||
tracking = true;
|
||||
startY = e.touches[0].clientY;
|
||||
setPull(0, false);
|
||||
}, { passive: true });
|
||||
|
||||
historyEl.addEventListener('touchmove', (e) => {
|
||||
if (!tracking || refreshing) return;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (dy <= 0) {
|
||||
setPull(0, false);
|
||||
return;
|
||||
}
|
||||
if (historyEl.scrollTop <= 0) {
|
||||
e.preventDefault();
|
||||
setPull(dy * 0.62, true);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
historyEl.addEventListener('touchend', () => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
if (pullY >= THRESHOLD) runRefresh();
|
||||
else setPull(0, false);
|
||||
}, { passive: true });
|
||||
|
||||
historyEl.addEventListener('touchcancel', () => {
|
||||
tracking = false;
|
||||
if (!refreshing) setPull(0, false);
|
||||
}, { passive: true });
|
||||
})();
|
||||
|
||||
// Auto-focus message input on load
|
||||
const msgEl = document.getElementById('message');
|
||||
if (msgEl) msgEl.focus();
|
||||
|
||||
+117
-71
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content, viewport-fit=cover" />
|
||||
<title>Odysseus Chat</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
@@ -90,6 +91,18 @@
|
||||
var _us = localStorage.getItem('odysseus-ui-scale');
|
||||
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
|
||||
} catch(e){}
|
||||
// Restore the sidebar mode before first paint. Otherwise the icon rail
|
||||
// and full sidebar can both flash visible until sidebar-layout.js runs.
|
||||
try {
|
||||
var _mobileStartup = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
if (_mobileStartup) {
|
||||
document.documentElement.classList.add('ody-sidebar-off', 'ody-mobile-startup-sidebar-hidden');
|
||||
} else {
|
||||
var _sm = localStorage.getItem('odysseus-sidebar-mode') || 'full';
|
||||
if (_sm === 'mini') document.documentElement.classList.add('ody-sidebar-mini');
|
||||
else if (_sm === 'off') document.documentElement.classList.add('ody-sidebar-off');
|
||||
}
|
||||
} catch(e){}
|
||||
// Apply background pattern on body once available
|
||||
if (t && t.bgPattern && t.bgPattern !== 'none') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -201,6 +214,22 @@
|
||||
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
|
||||
@media (max-width: 768px) {
|
||||
html.ody-sidebar-off .sidebar,
|
||||
html.ody-mobile-startup-sidebar-hidden .sidebar {
|
||||
transform: translateX(-100%) !important;
|
||||
pointer-events: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
html.ody-sidebar-off .sidebar.right-side,
|
||||
html.ody-mobile-startup-sidebar-hidden .sidebar.right-side {
|
||||
transform: translateX(100%) !important;
|
||||
}
|
||||
html.ody-sidebar-off .icon-rail,
|
||||
html.ody-mobile-startup-sidebar-hidden .icon-rail {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- KaTeX CSS is loaded with media="print" so it doesn't block render,
|
||||
then flipped to "all" via JS after load. Mermaid init runs once the
|
||||
@@ -219,29 +248,43 @@
|
||||
}, { once: true });
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260630mdfontsize">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260630mdfontsize">
|
||||
<link rel="modulepreload" href="/static/js/chat.js">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260722ctxheader4">
|
||||
<link rel="modulepreload" href="/static/js/ui.js">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js?v=20260722ctxheader4">
|
||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content, viewport-fit=cover" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loading overlay — hides all flashing until app is ready -->
|
||||
<div id="app-loader" style="position:fixed;inset:0;z-index:99999;background:var(--bg,#282c34);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;transition:opacity .3s">
|
||||
<div id="loader-wave" style="color:var(--brand-color,var(--red,#e06c75));font-family:monospace;font-size:11px;opacity:.5">▁▂▃</div>
|
||||
<div id="loader-wave" style="color:color-mix(in srgb, #9cdef2 62%, var(--brand-color,var(--red,#e06c75)));font-family:monospace;font-size:11px;opacity:.5;position:relative;padding-top:10px;line-height:1;white-space:pre">▁▂▃</div>
|
||||
</div>
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
var el=document.getElementById('loader-wave');
|
||||
if(!el)return;
|
||||
var frames=['▁▂▃','▂▃▄','▃▄▅','▄▅▆','▅▆▅','▆▅▄','▅▄▃','▄▃▂','▃▂▁'];
|
||||
var frames=[
|
||||
{wave:'▁▂▃',y:9},
|
||||
{wave:'▂▃▄',y:7},
|
||||
{wave:'▃▄▅',y:5},
|
||||
{wave:'▄▅▆',y:3},
|
||||
{wave:'▅▆▅',y:1},
|
||||
{wave:'▆▅▄',y:3},
|
||||
{wave:'▅▄▃',y:5},
|
||||
{wave:'▄▃▂',y:7},
|
||||
{wave:'▃▂▁',y:9}
|
||||
];
|
||||
var i=0;
|
||||
function render(){
|
||||
var f=frames[i%frames.length];
|
||||
el.innerHTML='<span style="position:absolute;left:50%;top:0;transform:translate(-50%,'+f.y+'px);font-size:1.24em;line-height:1">•</span><span>'+f.wave+'</span>';
|
||||
i++;
|
||||
}
|
||||
render();
|
||||
var iv=setInterval(function(){
|
||||
if(!document.getElementById('app-loader')){clearInterval(iv);return}
|
||||
el.textContent=frames[i%frames.length];
|
||||
i++;
|
||||
render();
|
||||
},150);
|
||||
setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
|
||||
})();
|
||||
@@ -474,11 +517,11 @@
|
||||
<!-- Tab: Browse themes -->
|
||||
<div id="theme-tab-browse" class="theme-tab-panel">
|
||||
<div class="admin-card">
|
||||
<h2>Default Themes</h2>
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><circle cx="12" cy="12" r="10"/><path d="M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8"/></svg>Default Themes</h2>
|
||||
<div class="theme-grid" id="themeGrid"></div>
|
||||
</div>
|
||||
<div class="admin-card" id="themeUserCard" style="display:none">
|
||||
<h2>Your Themes</h2>
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Your Themes</h2>
|
||||
<div class="theme-grid" id="themeUserGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,7 +529,7 @@
|
||||
<!-- Tab: Customize -->
|
||||
<div id="theme-tab-customize" class="theme-tab-panel" style="display:none">
|
||||
<div class="admin-card">
|
||||
<h2>Colors</h2>
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M9.06 11.9l-3.5 3.5a2.85 2.85 0 1 0 4.03 4.03l8.49-8.49a4.5 4.5 0 1 0-6.36-6.36L3.18 12.62"/><path d="M14 7l3 3"/></svg>Colors</h2>
|
||||
<div class="theme-custom" id="themeCustom">
|
||||
<div class="color-row"><label>Background</label><input type="color" id="clr-bg"><button class="color-reset-btn" data-reset="bg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
@@ -579,7 +622,7 @@
|
||||
<div id="harmony-preview" class="harmony-preview"></div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2>Font & Layout</h2>
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>Font & Layout</h2>
|
||||
<div class="theme-fd-row">
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Font</label>
|
||||
@@ -648,7 +691,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-top:8px;">
|
||||
<h2>Save / Share</h2>
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;opacity:0.6"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save / Share</h2>
|
||||
<div class="theme-save-row" id="theme-save-row">
|
||||
<input type="text" id="theme-save-name" placeholder="Theme name..." maxlength="32">
|
||||
<button id="theme-save-go">Save</button>
|
||||
@@ -686,7 +729,7 @@
|
||||
<button class="icon-rail-btn rail-dynamic" id="rail-documents" title="Documents" style="display:none"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg></button>
|
||||
<!-- Tool launchers — always visible, alphabetical -->
|
||||
<button class="icon-rail-btn" id="rail-calendar" title="Calendar"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-compare" title="Compare"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-compare" title="Compare"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-cookbook" title="Cookbook"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-research" title="Deep Research"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-email" title="Email"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg></button>
|
||||
@@ -803,35 +846,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="models-section">
|
||||
<div class="section-header-flex">
|
||||
<span class="section-title"> Models</span>
|
||||
<div style="position:relative; display:inline-block;">
|
||||
<button type="button" class="section-header-btn" id="model-sort-btn" title="Sort models">
|
||||
<svg class="sort-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="4" y1="12" x2="14" y2="12"/>
|
||||
<line x1="4" y1="18" x2="9" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="model-sort-dropdown" class="dropdown sort-dropdown" style="display:none;">
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="alpha">A-Z</div>
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="last-used">Last used</div>
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="most-used">Most used</div>
|
||||
<div class="dropdown-item rearrange-toggle sort-dropdown-item sort-dropdown-sep" id="model-rearrange-toggle">
|
||||
↑↓ Rearrange <span class="rearrange-check" style="float:right; opacity:0;">•</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="models">
|
||||
<div class="models-row">
|
||||
<select id="model-select" aria-label="Select model" style="flex: 1; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg);"></select>
|
||||
<button type="button" id="btn-model-chat" class="model-chat-btn" aria-label="Add model chat" style="transition: all 0.2s ease;"><span class="model-chat-btn-label">+ Chat</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="tools-section">
|
||||
<div class="section-header-flex">
|
||||
<span class="section-title"><svg class="section-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Tools</span>
|
||||
@@ -858,7 +872,7 @@
|
||||
<span class="grow">Calendar</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-compare-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.5;"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.5;"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>
|
||||
<span class="grow">Compare</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-cookbook-btn">
|
||||
@@ -955,10 +969,10 @@
|
||||
<h1 class="a11y-visually-hidden">Odysseus</h1>
|
||||
<div class="chat-top-bar">
|
||||
<button type="button" class="incognito-indicator" id="incognito-indicator" title="Nobody mode active — click to deactivate" style="display:none;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg></button>
|
||||
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item" id="export-doc-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span><span>Save to Documents</span></div></div></span></div> </div>
|
||||
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><button type="button" class="chat-context-pill" id="chat-context-pill" title="Chat context" hidden><span class="chat-context-dot"></span><span id="chat-context-pill-label">0%</span></button><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-compact-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M7 12h10"/><path d="M10 17h4"/></svg></span><span class="export-compact-pill">Compact</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item" id="export-doc-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span><span>Save to Documents</span></div><div class="export-dropdown-item dropdown-item-danger" id="export-delete-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span><span>Delete Chat</span></div></div></span></div> </div>
|
||||
<div id="welcome-screen">
|
||||
<div class="welcome-name"><svg class="welcome-boat" viewBox="0 0 32 32"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>Odysseus</div>
|
||||
<div class="welcome-sub" id="welcome-sub">Welcome, <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">type /setup</span> to get started.</div>
|
||||
<div class="welcome-sub" id="welcome-sub">New chat ready.</div>
|
||||
<div class="welcome-tip" id="welcome-tip"></div>
|
||||
<button type="button" class="incognito-btn" id="incognito-btn" title="Enable Nobody mode — no memory, no history saved">
|
||||
<svg class="eye-open" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -991,7 +1005,7 @@
|
||||
var tips = mobile ? phone : desktop;
|
||||
var el = document.getElementById('welcome-tip');
|
||||
if (el) {
|
||||
el.textContent = 'Type /setup, then choose Local models or API.';
|
||||
el.textContent = 'Pick a model if you want, or just type.';
|
||||
}
|
||||
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
||||
if (d.version) window._appVersion = d.version;
|
||||
@@ -1014,6 +1028,12 @@
|
||||
<div class="chat-input-top">
|
||||
<div id="message-ghost" class="ghost-text-overlay" aria-hidden="true"></div>
|
||||
<textarea id="message" placeholder="Message Odysseus..." required autocomplete="off" aria-label="Message input" rows="1" autofocus></textarea>
|
||||
<div id="plan-mode-status" class="plan-mode-status" hidden>
|
||||
<span class="plan-mode-status-label">Plan mode</span>
|
||||
<button type="button" id="plan-mode-status-toggle" class="plan-mode-status-toggle" aria-label="Turn off plan mode" aria-pressed="true">
|
||||
<span></span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Model picker (inside chatbox, top-right) -->
|
||||
<div class="model-picker-wrap" id="model-picker-wrap">
|
||||
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
|
||||
@@ -1032,6 +1052,30 @@
|
||||
<div class="model-picker-list" id="model-picker-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
function applyDefault(dc) {
|
||||
if (!dc || !dc.model) return false;
|
||||
window.__odysseusDefaultChat = dc;
|
||||
var label = document.getElementById('model-picker-label');
|
||||
if (!label) return false;
|
||||
label.textContent = String(dc.model).split('/').pop();
|
||||
label.title = dc.model;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
var dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
|
||||
if (applyDefault(dc)) return;
|
||||
} catch (_) {}
|
||||
fetch('/api/default-chat', { credentials: 'same-origin' })
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(dc){
|
||||
if (!applyDefault(dc)) return;
|
||||
try { localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc)); } catch (_) {}
|
||||
})
|
||||
.catch(function(){});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
<div id="pinned-tools-bar"></div>
|
||||
<div class="chat-input-bottom" style="visibility:hidden">
|
||||
@@ -1102,7 +1146,7 @@
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<svg class="workspace-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -1135,7 +1179,7 @@
|
||||
</button>
|
||||
<!-- Compare toolbar indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Compare active — click to deactivate" id="compare-indicator-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>
|
||||
<span style="font-size:11px;margin-left:2px;">Compare</span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -1154,6 +1198,7 @@
|
||||
<!-- Hidden checkboxes for state -->
|
||||
<input type="checkbox" id="web-toggle" style="display:none;">
|
||||
<input type="checkbox" id="bash-toggle" style="display:none;">
|
||||
<input type="checkbox" id="plan-toggle" style="display:none;">
|
||||
</div>
|
||||
<form id="chat-form" autocomplete="off" action="javascript:void(0);" style="display:none;"></form>
|
||||
|
||||
@@ -1748,11 +1793,6 @@
|
||||
<span class="vis-label">Email</span>
|
||||
<input type="checkbox" checked data-ui-key="email-section"><span class="vis-switch"></span>
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></span>
|
||||
<span class="vis-label">Models <span class="vis-hint">Model selector & quick-chat</span></span>
|
||||
<input type="checkbox" checked data-ui-key="models-section"><span class="vis-switch"></span>
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span>
|
||||
<span class="vis-label">Tools <span class="vis-hint">Whole section (header + all tools)</span></span>
|
||||
@@ -1769,7 +1809,7 @@
|
||||
<input type="checkbox" checked data-ui-key="tool-calendar"><span class="vis-switch"></span>
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg></span>
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg></span>
|
||||
<span class="vis-label">Compare</span>
|
||||
<input type="checkbox" checked data-ui-key="tool-compare"><span class="vis-switch"></span>
|
||||
</label>
|
||||
@@ -1963,6 +2003,14 @@
|
||||
|
||||
<!-- ═══ EMAIL TAB ═══ -->
|
||||
<div data-settings-panel="email" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>Email Settings</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div class="admin-toggle-sub" style="margin:0;flex:1;">Auto reply, newsletter unsubscribe, and writing style live in the Email window.</div>
|
||||
<button class="admin-btn-add" id="set-email-open-library-settings" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M9 18l6-6-6-6"/></svg>Open Email Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
@@ -2236,7 +2284,7 @@
|
||||
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Local
|
||||
</div>
|
||||
<div id="adm-epList-local"><div class="admin-empty">Loading...</div></div>
|
||||
<div id="adm-epList-local"><div class="admin-empty">No local endpoints yet.</div></div>
|
||||
</div>
|
||||
<div class="adm-ep-section" style="margin-top:18px;">
|
||||
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
|
||||
@@ -2354,7 +2402,7 @@
|
||||
<div id="adm-backupMsg" style="margin-top:6px;"></div>
|
||||
</div>
|
||||
<div class="admin-card admin-danger-card">
|
||||
<h2 style="color:#e55;">Danger Zone</h2>
|
||||
<h2 style="color:#e55;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.75"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Danger Zone</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Irreversible. Each wipe targets one category — pick exactly what you want gone.</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
@@ -2456,36 +2504,34 @@
|
||||
<script type="module" src="/static/js/ui.js"></script>
|
||||
<script type="module" src="/static/js/markdown.js"></script>
|
||||
<script type="module" src="/static/js/dragSort.js"></script>
|
||||
<script type="module" src="/static/js/sessions.js"></script>
|
||||
<script type="module" src="/static/js/memory.js"></script>
|
||||
<script type="module" src="/static/js/sessions.js?v=20260722ctxheader4"></script>
|
||||
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
|
||||
<script type="module" src="/static/js/skills.js"></script>
|
||||
<script type="module" src="/static/js/tourHints.js"></script>
|
||||
<script type="module" src="/static/js/tourAutoplay.js"></script>
|
||||
<script type="module" src="/static/js/fileHandler.js"></script>
|
||||
<script type="module" src="/static/js/voiceRecorder.js"></script>
|
||||
<script type="module" src="/static/js/models.js"></script> <!-- This must come BEFORE app.js -->
|
||||
<script type="module" src="/static/js/models.js?v=20260715startupcalm2"></script> <!-- This must come BEFORE app.js -->
|
||||
<script type="module" src="/static/js/rag.js"></script>
|
||||
<script type="module" src="/static/js/presets.js"></script>
|
||||
<script type="module" src="/static/js/search.js"></script>
|
||||
<script type="module" src="/static/js/spinner.js"></script>
|
||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||
<script type="module" src="/static/js/document.js"></script>
|
||||
<script type="module" src="/static/js/gallery.js"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260630toolmetrics"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260630toolmetrics"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260722ctxheader4"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/compare/index.js"></script>
|
||||
<script type="module" src="/static/js/theme.js"></script>
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js"></script>
|
||||
<script type="module" src="/static/js/admin.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
|
||||
<script type="module" src="/static/js/assistant.js"></script>
|
||||
<script type="module" src="/static/app.js?v=20260630mdfontsize"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js"></script>
|
||||
<script type="module" src="/static/app.js?v=20260723tasksbulkfeedback1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||
</body>
|
||||
|
||||
+44
-23
@@ -466,21 +466,22 @@ async function _selectAddedModelInChat(endpoint) {
|
||||
async function loadEndpoints() {
|
||||
const listLocal = el('adm-epList-local');
|
||||
const listApi = el('adm-epList-api');
|
||||
// Fallback to the legacy single list if the split containers don't exist
|
||||
// (older HTML or third-party embedding).
|
||||
const listLegacy = el('adm-epList');
|
||||
// Refresh model picker so new endpoints show up in chat
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(true);
|
||||
// Render endpoint rows first. Do not make Added Models wait on /api/models or
|
||||
// endpoint probes; explicit Refresh/Probe actions do that work.
|
||||
const refreshDependentModelUi = (force = false) => {
|
||||
setTimeout(() => {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(!!force, force ? {} : { cacheOnly: true }).then(() => {
|
||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||
window.sessionModule.updateModelPicker();
|
||||
}
|
||||
}, 1500);
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||
settingsModule.refreshAiModelEndpoints();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
|
||||
@@ -495,13 +496,16 @@ async function loadEndpoints() {
|
||||
const empty = '<div class="admin-empty">None</div>';
|
||||
if (listLocal) listLocal.innerHTML = empty;
|
||||
if (listApi) listApi.innerHTML = '<div class="admin-empty">None</div>';
|
||||
if (listLegacy) listLegacy.innerHTML = empty;
|
||||
refreshDependentModelUi();
|
||||
return;
|
||||
}
|
||||
const rowHtml = data.map(ep => {
|
||||
const epModels = Array.isArray(ep.models) ? ep.models : [];
|
||||
const visibleCount = epModels.length;
|
||||
const totalCount = visibleCount + (ep.hidden_count || 0);
|
||||
const pinnedModels = Array.isArray(ep.pinned_models) ? ep.pinned_models : [];
|
||||
const visibleCount = ep.picker_requires_pinning ? pinnedModels.length : epModels.length;
|
||||
const totalCount = Number.isFinite(Number(ep.model_count))
|
||||
? Number(ep.model_count)
|
||||
: visibleCount + (ep.hidden_count || 0);
|
||||
// `ep.models` is the *visible* set — when every model is hidden it's
|
||||
// empty, but we still need to render the expand panel so the user can
|
||||
// un-hide them. Gate on the total instead.
|
||||
@@ -562,17 +566,21 @@ async function loadEndpoints() {
|
||||
apiIdx.sort(_sortByEnabled);
|
||||
_renderInto(listLocal, localIdx);
|
||||
_renderInto(listApi, apiIdx);
|
||||
if (listLegacy) listLegacy.innerHTML = rowHtml.join('');
|
||||
// Iterate matching nodes across both containers.
|
||||
const queryAll = (sel) => {
|
||||
const out = [];
|
||||
[listLocal, listApi, listLegacy].forEach(c => {
|
||||
[listLocal, listApi].forEach(c => {
|
||||
if (c) c.querySelectorAll(sel).forEach(n => out.push(n));
|
||||
});
|
||||
return out;
|
||||
};
|
||||
queryAll('[data-adm-toggle-ep]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); });
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' });
|
||||
await _refreshAfterEndpointChange();
|
||||
loadEndpoints();
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-copy-url]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
@@ -663,6 +671,8 @@ async function loadEndpoints() {
|
||||
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
|
||||
const renderModels = (models, warning = '') => {
|
||||
const sortedModels = sortModelObjects(models);
|
||||
const usesPinnedPicker = sortedModels.some(m => !!m.picker_requires_pinning);
|
||||
panel.dataset.pickerMode = usesPinnedPicker ? 'pinned' : 'hidden';
|
||||
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
|
||||
const attachRefresh = () => {
|
||||
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
|
||||
@@ -674,6 +684,7 @@ async function loadEndpoints() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const refreshedModels = await res.json();
|
||||
renderModels(refreshedModels, refreshWarning);
|
||||
_refreshAfterEndpointChange();
|
||||
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
|
||||
} catch (_) {
|
||||
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
|
||||
@@ -684,26 +695,26 @@ async function loadEndpoints() {
|
||||
panel.innerHTML = `<div class="mcp-tools-header">
|
||||
<span>Models</span>
|
||||
<span style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="mcp-tools-count">0/0 enabled</span>
|
||||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||||
</span>
|
||||
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
|
||||
attachRefresh();
|
||||
return;
|
||||
}
|
||||
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
|
||||
const enabledCount = usesPinnedPicker
|
||||
? sortedModels.filter(m => m.is_pinned).length
|
||||
: sortedModels.filter(m => !m.is_hidden).length;
|
||||
const showSearch = sortedModels.length >= 8;
|
||||
panel.innerHTML = `<div class="mcp-tools-header">
|
||||
<span>Models</span>
|
||||
<span style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
||||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||||
<a href="#" data-ep-select-all="${epId}">All</a>
|
||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||
</span>
|
||||
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
||||
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
|
||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${(usesPinnedPicker ? m.is_pinned : !m.is_hidden) ? 'checked' : ''}>
|
||||
<span class="adm-check-dot" aria-hidden="true"></span>
|
||||
<span>${esc(m.display)}</span>
|
||||
</label>`
|
||||
@@ -744,35 +755,43 @@ async function loadEndpoints() {
|
||||
}
|
||||
});
|
||||
});
|
||||
refreshDependentModelUi();
|
||||
} catch (e) {
|
||||
const err = '<div class="admin-error">Failed to load</div>';
|
||||
[listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; });
|
||||
[listLocal, listApi].forEach(c => { if (c) c.innerHTML = err; });
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveEpModelState(epId, panel) {
|
||||
const hidden = [];
|
||||
const pinned = [];
|
||||
const usesPinnedPicker = panel && panel.dataset && panel.dataset.pickerMode === 'pinned';
|
||||
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
if (!cb.checked) hidden.push(cb.dataset.epModelId);
|
||||
if (cb.checked) pinned.push(cb.dataset.epModelId);
|
||||
else hidden.push(cb.dataset.epModelId);
|
||||
});
|
||||
const total = panel.querySelectorAll('input[type=checkbox]').length;
|
||||
const enabled = usesPinnedPicker ? pinned.length : total - hidden.length;
|
||||
try {
|
||||
await fetch(`/api/model-endpoints/${epId}/models`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ hidden }),
|
||||
body: JSON.stringify(usesPinnedPicker ? { pinned_models: pinned } : { hidden }),
|
||||
});
|
||||
const countLabel = panel.querySelector('.mcp-tools-count');
|
||||
if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`;
|
||||
const row = panel.closest('[data-adm-ep-id]');
|
||||
if (row) {
|
||||
const badge = row.querySelector('.admin-badge');
|
||||
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
|
||||
if (badge && !badge.classList.contains('admin-badge-off')) {
|
||||
const match = String(badge.textContent || '').match(/\/(\d+)/);
|
||||
const canonicalTotal = match ? Number(match[1]) : total;
|
||||
badge.textContent = `${enabled}/${canonicalTotal} models enabled`;
|
||||
}
|
||||
}
|
||||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||
settingsModule.refreshAiModelEndpoints();
|
||||
}
|
||||
_refreshAfterEndpointChange();
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
@@ -1480,6 +1499,7 @@ function initEndpointForm() {
|
||||
})());
|
||||
await Promise.all(workers);
|
||||
await loadEndpoints();
|
||||
await _refreshAfterEndpointChange();
|
||||
_refreshOfflineCount();
|
||||
if (uiModule && uiModule.showToast) {
|
||||
const ok = Math.max(0, ids.length - failed);
|
||||
@@ -1522,6 +1542,7 @@ function initEndpointForm() {
|
||||
await Promise.all(ids.map(id =>
|
||||
fetch('/api/model-endpoints/' + id, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {})
|
||||
));
|
||||
await _refreshAfterEndpointChange();
|
||||
try { await loadEndpoints(); } catch (_) {}
|
||||
_refreshOfflineCount();
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(`Removed ${ids.length} offline endpoint${ids.length === 1 ? '' : 's'}`, 1800);
|
||||
|
||||
+608
-64
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
const CHAT_ABOUT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const COPY_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
const CHECK_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const PAPERCLIP_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
|
||||
|
||||
/** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */
|
||||
function _safeHref(url) {
|
||||
@@ -1197,7 +1198,7 @@ document.addEventListener('click', function(e) {
|
||||
} catch {}
|
||||
});
|
||||
} else if (kind === 'document') {
|
||||
import('./document.js').then(mod => {
|
||||
import('./document.js?v=20260722emailfastindex1').then(mod => {
|
||||
const open = mod.loadDocument
|
||||
|| mod.openDocument
|
||||
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
|
||||
@@ -1219,7 +1220,7 @@ document.addEventListener('click', function(e) {
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'email') {
|
||||
import('./emailLibrary.js').then(mod => {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (open) open({ uid: id });
|
||||
}).catch(() => {});
|
||||
@@ -1254,6 +1255,9 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
var esc = uiModule.esc;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg msg-ai generated-image-wrap';
|
||||
wrap.dataset.imageUrl = imageUrl || '';
|
||||
wrap.dataset.imageKey = String(imageId || imageUrl || '');
|
||||
if (imageId) wrap.dataset.imageId = imageId;
|
||||
|
||||
const role = document.createElement('div');
|
||||
role.className = 'role';
|
||||
@@ -1329,6 +1333,42 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
});
|
||||
actions.appendChild(dlBtn);
|
||||
|
||||
const reuseBtn = document.createElement('button');
|
||||
reuseBtn.className = 'footer-copy-btn';
|
||||
reuseBtn.type = 'button';
|
||||
reuseBtn.title = 'Attach image to new prompt';
|
||||
reuseBtn.innerHTML = PAPERCLIP_ICON;
|
||||
reuseBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
reuseBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(safeImageUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const ext = (blob.type || '').includes('jpeg') ? 'jpg'
|
||||
: (blob.type || '').includes('webp') ? 'webp'
|
||||
: (blob.type || '').includes('gif') ? 'gif'
|
||||
: 'png';
|
||||
const base = (prompt || 'generated-image').slice(0, 36).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'generated-image';
|
||||
const file = new File([blob], `${base}.${ext}`, { type: blob.type || 'image/png', lastModified: Date.now() });
|
||||
const mod = await import('./fileHandler.js');
|
||||
const addFiles = mod.addFiles || (mod.default && mod.default.addFiles);
|
||||
if (!addFiles) throw new Error('attachment handler unavailable');
|
||||
await addFiles([file], { skipCrop: true });
|
||||
const input = document.getElementById('message');
|
||||
if (input) input.focus();
|
||||
reuseBtn.innerHTML = CHECK_ICON;
|
||||
if (window.showToast) window.showToast('Image attached');
|
||||
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1400);
|
||||
} catch (err) {
|
||||
console.warn('Attach generated image failed', err);
|
||||
reuseBtn.textContent = '\u2717';
|
||||
if (window.showToast) window.showToast('Could not attach image');
|
||||
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1600);
|
||||
}
|
||||
});
|
||||
actions.appendChild(reuseBtn);
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'footer-copy-btn';
|
||||
editBtn.type = 'button';
|
||||
@@ -1447,8 +1487,12 @@ export function hideWelcomeScreen() {
|
||||
export function showWelcomeScreen() {
|
||||
const ws = document.getElementById('welcome-screen');
|
||||
const cc = document.getElementById('chat-container');
|
||||
const alreadyVisible = !!(ws && !ws.classList.contains('hidden'));
|
||||
if (ws) ws.classList.remove('hidden');
|
||||
if (cc) cc.classList.add('welcome-active');
|
||||
if (alreadyVisible) {
|
||||
return;
|
||||
}
|
||||
// Entering the New Chat / welcome state: discard any stale draft left in the
|
||||
// composer from the previous session so the input starts empty (issue #1343).
|
||||
// Switching between existing sessions loads them directly and does NOT call
|
||||
|
||||
@@ -7,7 +7,7 @@ import Storage from './storage.js';
|
||||
import themeModule from './theme.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import documentModule from './document.js';
|
||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
||||
|
||||
/**
|
||||
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||
@@ -156,7 +156,7 @@ export function handleUIControl(uiData) {
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'email') {
|
||||
import('./emailLibrary.js').then(function(mod) {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) {
|
||||
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
|
||||
} catch (e) {
|
||||
console.warn('open_email_reply existing draft update failed:', e);
|
||||
}
|
||||
import('./emailInbox.js').then(function(mod) {
|
||||
import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) {
|
||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||
}).catch(function(e) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
|
||||
SEND_SVG, VOTES_STORAGE_KEY,
|
||||
} from './icons.js';
|
||||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
|
||||
import {
|
||||
@@ -359,7 +359,7 @@ async function _buildCompareUI() {
|
||||
headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;';
|
||||
const headerIcon = document.createElement('span');
|
||||
headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;';
|
||||
headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>';
|
||||
headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>';
|
||||
headerLeft.appendChild(headerIcon);
|
||||
headerLeft.appendChild(headerLabel);
|
||||
headerBar.appendChild(headerLeft);
|
||||
|
||||
@@ -75,7 +75,7 @@ async function showModelSelector() {
|
||||
header.className = 'modal-header';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>Model Comparison';
|
||||
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>Model Comparison';
|
||||
// Absorb the free space so the injected minimize (_) and close (✕) cluster
|
||||
// together on the right instead of being spread apart by space-between.
|
||||
title.style.marginRight = 'auto';
|
||||
|
||||
@@ -1,61 +1,171 @@
|
||||
/**
|
||||
* ArrowUp on an empty composer recalls the last user message (chat-app convention).
|
||||
* ArrowUp on the composer recalls previous user messages from this chat.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Last user bubble in the active chat surface (#chat-history), using dataset.raw
|
||||
* (same source as resend/regenerate in chat.js).
|
||||
* User bubbles in the active chat surface (#chat-history), newest first, using
|
||||
* dataset.raw (same source as resend/regenerate in chat.js).
|
||||
*
|
||||
* @param {Document | Element} [root=document]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getUserMessagesFromChatHistory(root = document) {
|
||||
const chatBox =
|
||||
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
|
||||
? root
|
||||
: (root.getElementById ? root.getElementById('chat-history') : null);
|
||||
if (!chatBox) return [];
|
||||
|
||||
const users = chatBox.querySelectorAll('.msg-user');
|
||||
const prompts = [];
|
||||
for (let i = users.length - 1; i >= 0; i--) {
|
||||
const msg = users[i];
|
||||
const bodyEl = msg.querySelector('.body');
|
||||
const text = msg.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
|
||||
if (text) prompts.push(text);
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last user bubble in the active chat surface (#chat-history).
|
||||
*
|
||||
* @param {Document | Element} [root=document]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getLastUserMessageFromChatHistory(root = document) {
|
||||
const chatBox =
|
||||
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
|
||||
? root
|
||||
: (root.getElementById ? root.getElementById('chat-history') : null);
|
||||
if (!chatBox) return '';
|
||||
|
||||
const users = chatBox.querySelectorAll('.msg-user');
|
||||
const last = users[users.length - 1];
|
||||
if (!last) return '';
|
||||
|
||||
const bodyEl = last.querySelector('.body');
|
||||
return last.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
|
||||
return getUserMessagesFromChatHistory(root)[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} composer
|
||||
* @param {() => string} getLastUserMessage
|
||||
* @param {() => string|string[]} getUserMessages
|
||||
* @param {{ autoResize?: (el: HTMLTextAreaElement) => void }} [options]
|
||||
* @returns {boolean} true when wired (or already wired)
|
||||
*/
|
||||
export function wireArrowUpRecall(composer, getLastUserMessage, options = {}) {
|
||||
export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
|
||||
if (!composer) return false;
|
||||
if (composer._arrowUpRecallWired) return true;
|
||||
composer._arrowUpRecallWired = true;
|
||||
|
||||
const { autoResize } = options;
|
||||
let recallIndex = -1;
|
||||
let applyingRecall = false;
|
||||
let lastRecalledValue = '';
|
||||
let recallHistory = [];
|
||||
|
||||
const readHistory = () => {
|
||||
const value = getUserMessages?.();
|
||||
if (Array.isArray(value)) return value.filter(Boolean);
|
||||
return value ? [value] : [];
|
||||
};
|
||||
const norm = (value) => String(value || '').replace(/\r\n/g, '\n').trimEnd();
|
||||
const debug = (...args) => {
|
||||
try {
|
||||
if (localStorage.getItem('odysseusArrowRecallDebug') === '1') {
|
||||
console.debug('[arrow-recall]', ...args);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
composer.addEventListener('input', () => {
|
||||
if (applyingRecall) return;
|
||||
if (norm(composer.value) === norm(lastRecalledValue)) return;
|
||||
recallIndex = -1;
|
||||
lastRecalledValue = '';
|
||||
recallHistory = [];
|
||||
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
|
||||
});
|
||||
|
||||
composer.addEventListener('keydown', (e) => {
|
||||
// Only ArrowUp, no modifier keys, no IME composition
|
||||
if (e.key !== 'ArrowUp') return;
|
||||
// Prompt history: ArrowUp walks older, ArrowDown walks newer/back to blank.
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
if (e.isComposing) return;
|
||||
if (typeof window !== 'undefined' && window._ghostAutocomplete?.isActive?.()) return;
|
||||
|
||||
// Literal emptiness — intentional whitespace is not empty
|
||||
if (composer.value !== '') return;
|
||||
|
||||
const recalled = getLastUserMessage();
|
||||
if (!recalled) return;
|
||||
const freshHistory = readHistory();
|
||||
const history = freshHistory.length ? freshHistory : recallHistory;
|
||||
if (!history.length) {
|
||||
debug('skip:no-history', { value: composer.value });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawCurrentValue = String(composer.value || '');
|
||||
const currentValue = norm(rawCurrentValue);
|
||||
const recalledValue = norm(lastRecalledValue);
|
||||
let currentIndex = rawCurrentValue === ''
|
||||
? -1
|
||||
: history.findIndex((item) => norm(item) === currentValue);
|
||||
if (currentIndex < 0 && currentValue && currentValue === recalledValue) {
|
||||
currentIndex = recallIndex;
|
||||
}
|
||||
if (currentIndex < 0 && currentValue) {
|
||||
const markedIndex = Number(composer.dataset?.odysseusRecallIndex);
|
||||
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
|
||||
currentIndex = markedIndex;
|
||||
}
|
||||
}
|
||||
if (rawCurrentValue !== '' && currentIndex < 0) {
|
||||
debug('skip:draft-in-progress', { value: composer.value });
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation?.();
|
||||
e.stopImmediatePropagation?.();
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (currentIndex < 0) return;
|
||||
const nextIndex = currentIndex - 1;
|
||||
if (nextIndex < 0) {
|
||||
recallIndex = -1;
|
||||
recallHistory = history;
|
||||
applyingRecall = true;
|
||||
lastRecalledValue = '';
|
||||
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
|
||||
composer.value = '';
|
||||
try { composer.selectionStart = composer.selectionEnd = 0; } catch (_) {}
|
||||
if (autoResize) autoResize(composer);
|
||||
debug('handled-down-clear', { historyLength: history.length });
|
||||
setTimeout(() => { applyingRecall = false; }, 0);
|
||||
return;
|
||||
}
|
||||
const recalled = history[nextIndex];
|
||||
recallIndex = nextIndex;
|
||||
recallHistory = history;
|
||||
applyingRecall = true;
|
||||
lastRecalledValue = recalled;
|
||||
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
|
||||
composer.value = recalled;
|
||||
try { composer.selectionStart = composer.selectionEnd = recalled.length; } catch (_) {}
|
||||
if (autoResize) autoResize(composer);
|
||||
debug('handled-down', { nextIndex, recalled, historyLength: history.length });
|
||||
setTimeout(() => { applyingRecall = false; }, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowUp owns prompt history in the chat composer. If the current text
|
||||
// is not already a recalled prompt, start from newest instead of letting
|
||||
// the browser move the caret inside the textarea.
|
||||
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||
const recalled = history[nextIndex];
|
||||
if (!recalled) {
|
||||
debug('skip:no-recalled', { nextIndex, history });
|
||||
return;
|
||||
}
|
||||
|
||||
recallIndex = nextIndex;
|
||||
recallHistory = history;
|
||||
applyingRecall = true;
|
||||
lastRecalledValue = recalled;
|
||||
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
|
||||
composer.value = recalled;
|
||||
try {
|
||||
composer.selectionStart = composer.selectionEnd = recalled.length;
|
||||
} catch (_) {}
|
||||
if (autoResize) autoResize(composer);
|
||||
});
|
||||
debug('handled', { nextIndex, recalled, historyLength: history.length });
|
||||
setTimeout(() => { applyingRecall = false; }, 0);
|
||||
}, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// generic fallback for that backend.
|
||||
|
||||
// Recipes carry two variants per entry:
|
||||
// variants.pip → install into the configured venv via uv/pip
|
||||
// variants.pip → install into the configured venv via pip/uv
|
||||
// variants.docker → pull the official container image
|
||||
//
|
||||
// The renderer prepends a `source <venv>/bin/activate` for the pip variant
|
||||
@@ -55,7 +55,89 @@ const _RECIPES = [
|
||||
label: 'Any MLX model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['uv pip install -U mlx-lm'] },
|
||||
pip: { commands: ['python -m pip install -U mlx-lm'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mflux',
|
||||
label: 'mflux-compatible MLX image models',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U mflux fastapi uvicorn python-multipart'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'boogu_image_mlx',
|
||||
label: 'MLX image models (Boogu)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mlx_vlm',
|
||||
label: 'MLX image models (HiDream)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U fastapi uvicorn python-multipart mlx mlx-vlm "transformers>=4.57.0,<6.0" huggingface_hub safetensors numpy pillow tqdm sentencepiece hf_transfer'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mlx_lama_swift',
|
||||
label: 'MLX image editing (LaMa / MI-GAN)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: {
|
||||
commands: [
|
||||
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-inpaint',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-inpaint" "$HOME/.local/bin/odysseus-mlx-inpaint"',
|
||||
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mlx_ddcolor_swift',
|
||||
label: 'MLX image editing (DDColor)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: {
|
||||
commands: [
|
||||
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-colorize',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-colorize" "$HOME/.local/bin/odysseus-mlx-colorize"',
|
||||
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Diffusers ────────────────────────────────────────────────────────
|
||||
{
|
||||
backend: 'diffusers',
|
||||
label: 'Any Diffusers image model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U "diffusers[torch]" torchvision accelerate scipy python-multipart'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'krea_diffusers',
|
||||
label: 'Latest Diffusers from Git',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'sam_mask',
|
||||
label: 'SAM object mask tools',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U torch torchvision transformers accelerate pillow'] },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -85,7 +167,7 @@ export function recipeCommands(recipe, variant) {
|
||||
// Backends we surface a recipe panel for. Other rows in the Dependencies
|
||||
// list keep the existing flat Install/Reinstall button without an expand
|
||||
// affordance.
|
||||
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'llama_cpp']);
|
||||
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'mflux', 'boogu_image_mlx', 'mlx_vlm', 'mlx_lama_swift', 'mlx_ddcolor_swift', 'diffusers', 'krea_diffusers', 'sam_mask', 'llama_cpp']);
|
||||
|
||||
// All recipe entries for a given backend, in catalog order. The first one
|
||||
// is the model-specific match (when present); the last is always the
|
||||
|
||||
@@ -261,17 +261,6 @@ async function _clearGpuProcesses(panel) {
|
||||
await _runQuickCmd(panel, _gpuCleanupCommand());
|
||||
}
|
||||
|
||||
// Infer the gated base repo that single-file checkpoints need configs from
|
||||
function _inferBaseRepo(text) {
|
||||
if (!text) return null;
|
||||
const t = text.toLowerCase();
|
||||
if (t.includes('sd3.5') || t.includes('stable-diffusion-3.5')) return 'stabilityai/stable-diffusion-3.5-large';
|
||||
if (t.includes('sd3') || t.includes('stable-diffusion-3')) return 'stabilityai/stable-diffusion-3-medium-diffusers';
|
||||
if (t.includes('flux')) return 'black-forest-labs/FLUX.1-schnell';
|
||||
if (t.includes('sdxl') || t.includes('stable-diffusion-xl')) return 'stabilityai/stable-diffusion-xl-base-1.0';
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /tmux is required|tmux.*not found|tmux:\s*command not found|command not found:\s*tmux|No such file or directory:\s*['"]?tmux/i,
|
||||
@@ -450,11 +439,10 @@ export const ERROR_PATTERNS = [
|
||||
message: 'Single-file checkpoint needs a base model for missing components (text encoder, VAE). The base model may be gated — accept the license and set your HF token.',
|
||||
fixes: [
|
||||
{ label: 'Request access to base model', action: (panel, _text) => {
|
||||
// Extract gated repo from error, or infer from model name
|
||||
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
|
||||
const base = _text && _text.match(/config=([^\s,)]+)/i);
|
||||
const model = _text && _text.match(/load model from\s+(\S+)/i);
|
||||
const repo = (gated && gated[1]) || (base && base[1]) || _inferBaseRepo(_text);
|
||||
const repo = (gated && gated[1]) || (base && base[1]);
|
||||
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
|
||||
else if (model && model[1]) window.open('https://huggingface.co/' + model[1].replace(/[.]$/, ''), '_blank');
|
||||
}},
|
||||
@@ -464,13 +452,21 @@ export const ERROR_PATTERNS = [
|
||||
}},
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed/i,
|
||||
message: 'This image model uses a custom Diffusers pipeline that your launch environment does not know yet.',
|
||||
fixes: [
|
||||
{ label: 'Update image dependencies', action: () => _openCookbookDependencies('diffusers') },
|
||||
{ label: 'Copy diagnosis', action: (_panel, _text) => navigator.clipboard?.writeText(_text || '') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Entry Not Found.*model_index\.json|Could not load model.*Check diffusers/i,
|
||||
message: 'Single-file model — needs base config from a gated repo. Accept the license and set your HF token.',
|
||||
message: 'Single-file model may need an explicit base config. Add --single-file-config <repo_or_path> if the checkpoint is missing components.',
|
||||
fixes: [
|
||||
{ label: 'Request access to base model', action: (panel, _text) => {
|
||||
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
|
||||
const repo = (gated && gated[1]) || _inferBaseRepo(_text);
|
||||
const repo = gated && gated[1];
|
||||
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
|
||||
else window.open('https://huggingface.co/settings/gated-repos', '_blank');
|
||||
}},
|
||||
@@ -560,6 +556,15 @@ export const ERROR_PATTERNS = [
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['"]?mflux/i,
|
||||
message: 'MLX image serving requires mflux on this Apple Silicon server.',
|
||||
suggestion: 'Suggested action: install mflux in the selected Python environment. This is for MLX image generation, not text MLX-LM.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mflux') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mflux fastapi uvicorn') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Unable to quantize model of type <class ['"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['"]>|QuantizedSwitchLinear/i,
|
||||
message: 'MLX-LM tried to quantize an already-quantized DeepSeek switch layer.',
|
||||
@@ -725,11 +730,11 @@ export const ERROR_PATTERNS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /No module named ['"]?torch|No module named ['"]?diffusers|diffusers.*command not found/i,
|
||||
message: 'Diffusion serving needs PyTorch and diffusers. Install diffusers from Cookbook → Dependencies.',
|
||||
pattern: /No module named ['"]?torch|No module named ['"]?torchvision|No module named ['"]?diffusers|No module named ['"]?scipy|install scipy if you want to use beta sigmas|requires the Torchvision library|diffusers.*command not found/i,
|
||||
message: 'Diffusion serving needs PyTorch, Torchvision, Diffusers, Accelerate, and SciPy. Install Diffusers image deps from Cookbook → Dependencies.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('diffusers') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]"') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]" torchvision accelerate scipy python-multipart') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+173
-15
@@ -40,7 +40,13 @@ import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
// Map a serve-backend code (vllm / sglang / llamacpp / mlx) → the package name
|
||||
// the Dependencies API reports. Used to look up "is this backend installed
|
||||
// on the target server" before firing a launch.
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm' };
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', mlx_image: 'mflux', diffusers: 'diffusers' };
|
||||
function _dependencyPkgForModel(runBackend, modelName = '') {
|
||||
const nm = String(modelName || '').toLowerCase();
|
||||
if (runBackend === 'mlx_image' && nm.includes('boogu')) return 'boogu_image_mlx';
|
||||
if (runBackend === 'diffusers' && nm.includes('krea')) return 'krea_diffusers';
|
||||
return _BACKEND_PKG[runBackend];
|
||||
}
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
const d = String(dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
|
||||
@@ -95,7 +101,7 @@ function _wireServerColorPicker(entry) {
|
||||
// the target server. Returns true if it's good to go, false if we should
|
||||
// block and route the user into Dependencies.
|
||||
async function _ensureBackendInstalled(runBackend, host, port, envPath, modelName) {
|
||||
const pkgName = _BACKEND_PKG[runBackend];
|
||||
const pkgName = _dependencyPkgForModel(runBackend, modelName);
|
||||
if (!pkgName) return true; // unknown backend — don't block
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -542,9 +548,31 @@ function _hwfitShowError(list, host, detail) {
|
||||
// needed. Ollama rows are merged into the main list (see _ensureOllamaLib +
|
||||
// _ollamaToHwfitRows below) so the filter handles all engines uniformly.
|
||||
function _applyEngineFilter(models) {
|
||||
let out = Array.isArray(models) ? models : [];
|
||||
const useCase = document.getElementById('hwfit-usecase')?.value || '';
|
||||
const srv = _serverByVal(_envState.remoteServerKey || _envState.remoteHost);
|
||||
const platform = String(srv?.platform || _envState.platform || _hwfitCache?.system?.platform || '').toLowerCase();
|
||||
const backend = String(_hwfitCache?.system?.backend || '').toLowerCase();
|
||||
const gpuName = String(_hwfitCache?.system?.gpu_name || '').toLowerCase();
|
||||
const isAppleTarget = !!(useCase === 'image_gen' && (
|
||||
platform === 'darwin'
|
||||
|| platform === 'macos'
|
||||
|| platform.includes('mac')
|
||||
|| backend === 'metal'
|
||||
|| backend === 'mps'
|
||||
|| backend === 'apple'
|
||||
|| gpuName.includes('apple')
|
||||
|| _hwfitCache?.system?.unified_memory
|
||||
));
|
||||
if (isAppleTarget) {
|
||||
out = out.filter(m => {
|
||||
const text = `${m?.name || ''} ${m?.id || ''} ${m?.provider || ''}`.toLowerCase();
|
||||
return text.includes('mlx-community/') || text.includes('mlx-community') || m?.mlx_only || m?.apple_ok;
|
||||
});
|
||||
}
|
||||
const want = document.getElementById('hwfit-engine')?.value || '';
|
||||
if (!want || !Array.isArray(models)) return models || [];
|
||||
return models.filter(m => {
|
||||
if (!want) return out;
|
||||
return out.filter(m => {
|
||||
try { return _detectBackend(m).backend === want; } catch { return true; }
|
||||
});
|
||||
}
|
||||
@@ -794,7 +822,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (v !== '') params.set(k, v);
|
||||
});
|
||||
if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
|
||||
// Image models use a separate registry/endpoint
|
||||
// Image models use a separate registry/endpoint.
|
||||
const isImageMode = useCase === 'image_gen';
|
||||
if ((fresh || (_paintedFromCache && !search)) && !isImageMode) {
|
||||
params.set('refresh_catalog', '1'); // update HF-backed dynamic catalogs in the background
|
||||
@@ -840,7 +868,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Normalize image model fields to match LLM renderer expectations
|
||||
// Normalize image model fields to match LLM renderer expectations.
|
||||
if (isImageMode && data.models) {
|
||||
data.models = data.models.map(m => ({
|
||||
...m,
|
||||
@@ -1263,9 +1291,46 @@ export const _hwfitColumns = [
|
||||
{ key: null, label: 'Mode', cls: 'hwfit-c-mode' },
|
||||
];
|
||||
|
||||
function _sortHwfitRows(models) {
|
||||
const rows = Array.isArray(models) ? [...models] : [];
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
const sortKey = sortSel?.value || 'newest';
|
||||
const asc = sortSel?.dataset.reverse === '1';
|
||||
if (sortKey === 'fit') {
|
||||
const fitRank = { perfect: 4, good: 3, marginal: 2, too_tight: 1, no_fit: 0 };
|
||||
rows.sort((a, b) => {
|
||||
const ar = fitRank[a.fit_level] ?? -1;
|
||||
const br = fitRank[b.fit_level] ?? -1;
|
||||
if (ar !== br) return asc ? ar - br : br - ar;
|
||||
const as = Number(a.score) || 0;
|
||||
const bs = Number(b.score) || 0;
|
||||
return asc ? as - bs : bs - as;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
if (sortKey === 'newest') {
|
||||
rows.sort((a, b) => {
|
||||
const ad = String(a.release_date || '');
|
||||
const bd = String(b.release_date || '');
|
||||
if (ad === bd) return 0;
|
||||
if (!ad) return 1;
|
||||
if (!bd) return -1;
|
||||
return asc ? (ad < bd ? -1 : 1) : (ad < bd ? 1 : -1);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
|
||||
rows.sort((a, b) => {
|
||||
const av = Number(a[field]) || 0;
|
||||
const bv = Number(b[field]) || 0;
|
||||
return asc ? av - bv : bv - av;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function _hwfitRenderList(el, models) {
|
||||
if (!el) return;
|
||||
models = models || [];
|
||||
models = _sortHwfitRows(models);
|
||||
if (!models.length) {
|
||||
// Disambiguate WHY the list is empty so capable servers don't read as "too weak":
|
||||
// active filters vs. a likely under-reported probe vs. genuinely low hardware.
|
||||
@@ -1570,7 +1635,9 @@ export function _expandModelRow(row, modelData) {
|
||||
html += `</div>`;
|
||||
html += `<div class="hwfit-panel-actions">`;
|
||||
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
|
||||
if (!modelData.is_image_gen) {
|
||||
if (modelData.is_image_gen) {
|
||||
html += `<button class="cookbook-btn cookbook-run-btn hwfit-quickrun-btn" title="Download + run as an image endpoint">Run Image</button>`;
|
||||
} else {
|
||||
html += `<button class="cookbook-btn cookbook-run-btn hwfit-quickrun-btn" title="Download + launch with smart defaults">Run</button>`;
|
||||
html += `<button class="cookbook-btn hwfit-serve-expand-btn" title="Configure & serve">Configure</button>`;
|
||||
}
|
||||
@@ -1653,11 +1720,14 @@ export function _expandModelRow(row, modelData) {
|
||||
const _clashing = _allServes.filter(t => _taskPort(t) === _qrPort);
|
||||
if (_clashing.length) {
|
||||
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _ok = await window.styledConfirm?.(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it and launch this one?`,
|
||||
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
|
||||
const _choice = await window.styledConfirm?.(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it first, or launch anyway?`,
|
||||
{ title: `Port ${_qrPort} in use`, confirmText: 'Stop & launch', alternateText: 'Launch anyway', cancelText: 'Cancel' }
|
||||
);
|
||||
if (!_ok) return;
|
||||
if (!_choice) return;
|
||||
if (_choice === 'alternate') {
|
||||
uiModule.showToast('Launching anyway. If the port is already occupied, the new serve may fail.', 6000);
|
||||
} else {
|
||||
quickRunBtn.disabled = true;
|
||||
quickRunBtn.textContent = 'Stopping…';
|
||||
for (const t of _clashing) {
|
||||
@@ -1678,6 +1748,7 @@ export function _expandModelRow(row, modelData) {
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
|
||||
// -- Launch ───────────────────────────────────────────────────
|
||||
@@ -1808,9 +1879,12 @@ export function _expandModelRow(row, modelData) {
|
||||
cmd += ` --context-length ${maxCtx}`;
|
||||
cmd += ` --mem-fraction-static ${gpuUtil}`;
|
||||
cmd += ' --trust-remote-code';
|
||||
} else if (runBackend === 'mlx_image') {
|
||||
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd = `python3 scripts/mlx_image_server.py --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port} --steps 20`;
|
||||
} else if (runBackend === 'mlx') {
|
||||
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port}`;
|
||||
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port} --max-tokens ${maxCtx}`;
|
||||
} else if (runBackend === 'llamacpp') {
|
||||
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
|
||||
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
@@ -1852,7 +1926,7 @@ export function _expandModelRow(row, modelData) {
|
||||
);
|
||||
if (!_ok) {
|
||||
quickRunBtn.disabled = false;
|
||||
quickRunBtn.textContent = 'Run';
|
||||
quickRunBtn.textContent = modelData.is_image_gen ? 'Run Image' : 'Run';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1889,7 +1963,7 @@ export function _expandModelRow(row, modelData) {
|
||||
uiModule.showError('Launch failed: ' + e.message);
|
||||
}
|
||||
quickRunBtn.disabled = false;
|
||||
quickRunBtn.textContent = 'Run';
|
||||
quickRunBtn.textContent = modelData.is_image_gen ? 'Run Image' : 'Run';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1938,6 +2012,89 @@ function _hwfitEngineGlyph(value) {
|
||||
return _HWFIT_ENGINE_GLYPHS[value] || _HWFIT_ENGINE_GLYPHS[''];
|
||||
}
|
||||
|
||||
const _HWFIT_USECASE_GLYPHS = {
|
||||
general: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>',
|
||||
multimodal: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
|
||||
image_gen: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="M21 15l-5-5L5 21"></path></svg>',
|
||||
};
|
||||
|
||||
function _hwfitUsecaseGlyph(value) {
|
||||
return _HWFIT_USECASE_GLYPHS[value] || _HWFIT_USECASE_GLYPHS.general;
|
||||
}
|
||||
|
||||
function _bindHwfitUsecasePicker(usecase) {
|
||||
const wrap = usecase?.closest('.hwfit-usecase-wrap');
|
||||
const btn = wrap?.querySelector('[data-hwfit-usecase-btn]');
|
||||
const menu = wrap?.querySelector('[data-hwfit-usecase-menu]');
|
||||
const icon = wrap?.querySelector('[data-hwfit-usecase-icon]');
|
||||
const label = wrap?.querySelector('[data-hwfit-usecase-label]');
|
||||
if (!usecase || !wrap || !btn || !menu) return;
|
||||
usecase.querySelectorAll('option[value="video_gen"]').forEach((opt) => opt.remove());
|
||||
menu.querySelectorAll('[data-hwfit-usecase-value="video_gen"], .hwfit-usecase-item').forEach((item) => {
|
||||
if (item.dataset.hwfitUsecaseValue === 'video_gen' || item.textContent?.trim() === 'Video') item.remove();
|
||||
});
|
||||
if (usecase.value === 'video_gen') {
|
||||
usecase.value = 'general';
|
||||
usecase.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
if (wrap.dataset.usecasePickerBound) return;
|
||||
wrap.dataset.usecasePickerBound = '1';
|
||||
|
||||
const setOpen = (open) => {
|
||||
menu.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
const currentLabel = () => {
|
||||
const opt = Array.from(usecase.options).find((o) => o.value === usecase.value);
|
||||
return opt?.textContent || 'Standard';
|
||||
};
|
||||
const syncButton = () => {
|
||||
if (label) label.textContent = currentLabel();
|
||||
if (icon) icon.innerHTML = _hwfitUsecaseGlyph(usecase.value);
|
||||
menu.querySelectorAll('[data-hwfit-usecase-value]').forEach((item) => {
|
||||
const active = item.dataset.hwfitUsecaseValue === usecase.value;
|
||||
item.classList.toggle('active', active);
|
||||
item.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
const renderMenu = () => {
|
||||
menu.innerHTML = Array.from(usecase.options).filter((opt) => opt.value !== 'video_gen').map((opt) => (
|
||||
`<button type="button" role="option" class="hwfit-usecase-item" data-hwfit-usecase-value="${opt.value}">`
|
||||
+ `<span class="hwfit-usecase-item-icon" aria-hidden="true">${_hwfitUsecaseGlyph(opt.value)}</span>`
|
||||
+ `<span class="hwfit-usecase-item-label">${opt.textContent}</span>`
|
||||
+ '</button>'
|
||||
)).join('');
|
||||
menu.querySelectorAll('[data-hwfit-usecase-value]').forEach((item) => {
|
||||
item.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const next = item.dataset.hwfitUsecaseValue || 'general';
|
||||
if (usecase.value !== next) {
|
||||
usecase.value = next;
|
||||
usecase.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncButton();
|
||||
setOpen(false);
|
||||
});
|
||||
});
|
||||
syncButton();
|
||||
};
|
||||
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
usecase.addEventListener('change', syncButton);
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!wrap.contains(ev.target)) setOpen(false);
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') setOpen(false);
|
||||
});
|
||||
renderMenu();
|
||||
}
|
||||
|
||||
function _bindHwfitEnginePicker(engine) {
|
||||
const wrap = engine?.closest('.hwfit-engine-wrap');
|
||||
const btn = wrap?.querySelector('[data-hwfit-engine-btn]');
|
||||
@@ -2011,6 +2168,7 @@ export function _hwfitInit() {
|
||||
const search = document.getElementById('hwfit-search');
|
||||
const remote = document.getElementById('hwfit-host');
|
||||
_syncCtxControl();
|
||||
if (uc) _bindHwfitUsecasePicker(uc);
|
||||
if (uc) uc.addEventListener('change', () => _hwfitFetch());
|
||||
if (sort) sort.addEventListener('change', () => _hwfitFetch());
|
||||
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
|
||||
|
||||
+261
-90
@@ -504,6 +504,14 @@ export function _detectBackend(model) {
|
||||
const isRocm = sysBackend === 'rocm';
|
||||
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
|
||||
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
|
||||
const isImageModel = !!(model.is_image_gen || model.is_diffusion || model._tag === 'image');
|
||||
// Image gen models → diffusers
|
||||
if (isImageModel) {
|
||||
if (/\bmlx\b|mlx-|_mlx|mlx-community\//i.test(_nm) || q.startsWith('MLX') || model.mlx_only) {
|
||||
return { backend: 'mlx_image', label: 'MLX Image' };
|
||||
}
|
||||
return { backend: 'diffusers', label: 'Diffusers' };
|
||||
}
|
||||
if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
|
||||
return { backend: 'mlx', label: 'MLX' };
|
||||
}
|
||||
@@ -512,11 +520,6 @@ export function _detectBackend(model) {
|
||||
&& model.gguf_files.some(f => f && typeof f.rel_path === 'string' && /\.gguf$/i.test(f.rel_path));
|
||||
const isGgufLike = model.is_gguf || hasGgufFile || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf');
|
||||
|
||||
// Image gen models → diffusers
|
||||
if (model.is_image_gen || model.is_diffusion || model._tag === 'image') {
|
||||
return { backend: 'diffusers', label: 'Diffusers' };
|
||||
}
|
||||
|
||||
// AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them
|
||||
// through llama.cpp/Ollama just because the host is Mac/Windows; those engines
|
||||
// need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
|
||||
@@ -558,6 +561,18 @@ export function _shellQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function _listField(value) {
|
||||
return String(value || '')
|
||||
.split(/[\n,]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function _numField(value) {
|
||||
const s = String(value || '').trim();
|
||||
return /^-?\d+(?:\.\d+)?$/.test(s) ? s : '';
|
||||
}
|
||||
|
||||
export function _psQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "''") + "'";
|
||||
}
|
||||
@@ -709,6 +724,10 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim();
|
||||
if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8';
|
||||
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-num-seqs ${f.max_seqs.toString().trim()}`;
|
||||
const _vllmLoraModules = _listField(f.vllm_lora_modules);
|
||||
if (_vllmLoraModules.length) {
|
||||
cmd += ` --enable-lora --lora-modules ${_vllmLoraModules.map(_shellQuote).join(' ')}`;
|
||||
}
|
||||
if (f.enforce_eager) cmd += ' --enforce-eager';
|
||||
if (f.trust_remote) cmd += ' --trust-remote-code';
|
||||
if (f.prefix_cache) cmd += ' --enable-prefix-caching';
|
||||
@@ -917,7 +936,7 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
// Trailing GGUF_FILE is optional; helper picks the first match if empty.
|
||||
cmd = `docker exec ollama-test ollama-import ${modelName} ${_name} ${_ctx}${_file ? ' ' + _file : ''}`;
|
||||
} else if (!modelName.includes('/') && modelName) {
|
||||
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On kierkegaard the
|
||||
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On remote hosts the
|
||||
// runtime is the ROCm Ollama sidecar; this quick command verifies the
|
||||
// tag exists, then the backend auto-registers http://host.docker.internal:11434/v1.
|
||||
cmd = `docker exec ollama-rocm ollama show ${modelName}`;
|
||||
@@ -930,22 +949,54 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
const gpuStr = f.gpus?.trim();
|
||||
cmd += _gpuEnvPrefix(gpuStr);
|
||||
const diffusersPy = _isWindows() ? 'python' : _py3Bin;
|
||||
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
|
||||
const diffHost = f.host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --host ${diffHost} --port ${f.port || '8100'}`;
|
||||
if (f.host) {
|
||||
const allowedHost = String(f.host || '').split('@').pop().split(':')[0].trim();
|
||||
if (allowedHost) cmd += ` --allowed-host ${allowedHost}`;
|
||||
}
|
||||
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
|
||||
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
|
||||
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
|
||||
if (f.diff_guidance_scale) cmd += ` --guidance-scale ${_numField(f.diff_guidance_scale) || f.diff_guidance_scale}`;
|
||||
if (String(f.diff_negative_prompt || '').trim()) cmd += ` --negative-prompt ${_shellQuote(String(f.diff_negative_prompt || '').trim())}`;
|
||||
if (f.diff_width) cmd += ` --width ${f.diff_width}`;
|
||||
if (f.diff_height) cmd += ` --height ${f.diff_height}`;
|
||||
const _diffLoras = _listField(f.diff_lora);
|
||||
if (_diffLoras.length) cmd += ` --lora ${_shellQuote(_diffLoras.join(','))}`;
|
||||
const _diffLoraScale = _numField(f.diff_lora_scale);
|
||||
if (_diffLoraScale) cmd += ` --lora-scale ${_diffLoraScale}`;
|
||||
if (f.diff_offload) cmd += ' --cpu-offload';
|
||||
if (f.diff_attention_slicing) cmd += ' --attention-slicing';
|
||||
if (f.diff_vae_slicing) cmd += ' --vae-slicing';
|
||||
if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`;
|
||||
} else if (backend === 'mlx_image') {
|
||||
const mlxPy = _isWindows() ? 'python' : _py3Bin;
|
||||
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd += `${mlxPy} scripts/mlx_image_server.py --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8100'}`;
|
||||
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
|
||||
if (f.diff_width) cmd += ` --width ${f.diff_width}`;
|
||||
if (f.diff_height) cmd += ` --height ${f.diff_height}`;
|
||||
const _mlxBaseModel = String(f.mlx_base_model || '').trim();
|
||||
if (_mlxBaseModel) cmd += ` --base-model ${_shellQuote(_mlxBaseModel)}`;
|
||||
const _mlxLoraStyle = String(f.mlx_lora_style || '').trim();
|
||||
if (_mlxLoraStyle) cmd += ` --lora-style ${_shellQuote(_mlxLoraStyle)}`;
|
||||
const _mlxLoraPaths = _listField(f.mlx_lora_paths);
|
||||
if (_mlxLoraPaths.length) cmd += ` --lora-paths ${_mlxLoraPaths.map(_shellQuote).join(' ')}`;
|
||||
const _mlxLoraScales = _listField(f.mlx_lora_scales).filter(s => /^-?\d+(?:\.\d+)?$/.test(s));
|
||||
if (_mlxLoraScales.length) cmd += ` --lora-scales ${_mlxLoraScales.map(_shellQuote).join(' ')}`;
|
||||
} else if (backend === 'mlx') {
|
||||
const mlxPy = _isWindows() ? 'python' : _py3Bin;
|
||||
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd += `${mlxPy} -m mlx_lm.server --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8080'}`;
|
||||
const mlxMaxTokens = String(f.ctx || '').trim();
|
||||
if (/minimax|mini-max/i.test(modelName)) {
|
||||
cmd += ' --temp 0.7 --top-p 0.9 --max-tokens 2048';
|
||||
cmd += ` --temp 0.7 --top-p 0.9 --max-tokens ${mlxMaxTokens || '2048'}`;
|
||||
} else if (/^\d+$/.test(mlxMaxTokens)) {
|
||||
// MLX-LM server has no vLLM-style --context-length flag. The closest
|
||||
// server-side request budget it exposes is --max-tokens, so wire the
|
||||
// Cookbook Context/Auto control there for MLX launches.
|
||||
cmd += ` --max-tokens ${mlxMaxTokens}`;
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
@@ -1041,19 +1092,20 @@ async function _fetchDependencies() {
|
||||
try {
|
||||
// Resolve the target server from the deps dropdown so remote-target
|
||||
// packages are checked on THAT server's venv (not just the local host).
|
||||
let _depHost = '', _depPort = '', _depVenv = '';
|
||||
let _depHost = '', _depPort = '', _depVenv = '', _depPlatform = '';
|
||||
const _dsel = document.getElementById('hwfit-deps-server');
|
||||
const _depSrv = _dsel && _dsel.value !== 'local' ? _serverByVal(_dsel.value) : null;
|
||||
if (_depSrv) {
|
||||
_depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || '';
|
||||
_depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || ''; _depPlatform = _depSrv.platform || '';
|
||||
} else if (_envState.remoteHost) {
|
||||
_depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || '';
|
||||
_depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || ''; _depPlatform = _envState.platform || '';
|
||||
}
|
||||
const _pkgParams = new URLSearchParams();
|
||||
if (_depHost) {
|
||||
_pkgParams.set('host', _depHost);
|
||||
if (_depPort) _pkgParams.set('ssh_port', _depPort);
|
||||
if (_depVenv) _pkgParams.set('venv', _depVenv);
|
||||
if (_depPlatform) _pkgParams.set('platform', _depPlatform);
|
||||
}
|
||||
// Pass the detected backend so the server can build a single
|
||||
// OS+backend-aware install command per row (e.g. add nvidia-cuda-toolkit
|
||||
@@ -1063,6 +1115,13 @@ async function _fetchDependencies() {
|
||||
if (_depBackend && _hwfitCache?._scannedHost === _depHost) {
|
||||
_pkgParams.set('backend', _depBackend);
|
||||
}
|
||||
if (_cachedModelIds && _cachedModelIds.size) {
|
||||
const _hint = Array.from(_cachedModelIds)
|
||||
.filter(id => /krea/i.test(String(id || '')))
|
||||
.slice(0, 20)
|
||||
.join(',');
|
||||
if (_hint) _pkgParams.set('model_hint', _hint);
|
||||
}
|
||||
const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : ''));
|
||||
const data = await resp.json();
|
||||
const pkgs = data.packages || [];
|
||||
@@ -1098,9 +1157,13 @@ async function _fetchDependencies() {
|
||||
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mflux: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
|
||||
boogu_image_mlx: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 17c2.5-4 4.5-4 7 0"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
krea_diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V5"/><path d="M4 12h4"/><path d="M12 5l-7 7 7 7"/><path d="M14 19l3-14 3 14"/><path d="M15.3 13h3.4"/></svg>',
|
||||
sam_mask: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>',
|
||||
};
|
||||
const _depGlyphHtml = (name) => {
|
||||
const g = _DEP_GLYPHS[name];
|
||||
@@ -1143,23 +1206,6 @@ async function _fetchDependencies() {
|
||||
const _buildDepsBtn = _bdm.length
|
||||
? `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(_bdm.join(','))}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="Install ${esc(_bdm.join(', '))} via the OS package manager on this target (requires passwordless sudo or root).">Install build deps</button>`
|
||||
: '';
|
||||
// Render the target-specific install command as a compact mono box
|
||||
// when the server resolved it (target's /etc/os-release was readable
|
||||
// AND the backend is known). The box doubles as the source of truth
|
||||
// for the "Install build deps" button's failure toast — both surfaces
|
||||
// show the same string for the same target.
|
||||
const _instCmd = (_bdm.length && pkg.install_cmd_for_target) ? String(pkg.install_cmd_for_target) : '';
|
||||
const _instCmdOs = pkg.install_cmd_os ? String(pkg.install_cmd_os) : '';
|
||||
const _instCmdBe = pkg.install_cmd_backend ? String(pkg.install_cmd_backend) : '';
|
||||
const _instLabel = (_instCmdOs && _instCmdBe) ? `${_instCmdOs} + ${_instCmdBe}` : (_instCmdOs || _instCmdBe || 'this target');
|
||||
const _instCmdBox = _instCmd
|
||||
? `<div class="cookbook-dep-install-cmd" data-dep-cmd="${esc(_instCmd)}" style="margin-top:6px;font-size:10.5px;opacity:0.85;">`
|
||||
+ `<div style="opacity:0.65;margin-bottom:2px;">Install on ${esc(_instLabel)}:</div>`
|
||||
+ `<div style="display:flex;gap:4px;align-items:stretch;">`
|
||||
+ `<code style="flex:1;padding:4px 6px;background:color-mix(in srgb, var(--fg) 6%, transparent);border:1px solid var(--border);border-radius:4px;font-family:var(--mono, ui-monospace, monospace);font-size:10.5px;white-space:pre-wrap;word-break:break-all;">${esc(_instCmd)}</code>`
|
||||
+ `<button type="button" class="cookbook-dep-cmd-copy" data-dep-cmd-copy="${esc(_instCmd)}" title="Copy install command" style="padding:2px 8px;font-size:10px;border:1px solid var(--border);border-radius:4px;background:none;cursor:pointer;color:var(--fg-muted);">Copy</button>`
|
||||
+ `</div></div>`
|
||||
: '';
|
||||
// Partial-state row (replaces the cryptic yellow "Partial ▾" tag).
|
||||
// Renders inline as a yellow banner with two clear actions: one-tap
|
||||
// Install (runs the reinstall in cookbook) or Copy command (paste
|
||||
@@ -1179,7 +1225,6 @@ async function _fetchDependencies() {
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
+ note
|
||||
+ updateNote
|
||||
+ _instCmdBox
|
||||
+ `</div>`
|
||||
+ _rebuildBtn
|
||||
+ _buildDepsBtn
|
||||
@@ -1194,13 +1239,21 @@ async function _fetchDependencies() {
|
||||
// the user sees a paste-ready sequence; Run keeps using env_prefix to
|
||||
// activate the same venv before the pip command. Docker variant skips
|
||||
// the activate line — `docker pull` doesn't need a venv.
|
||||
function _recipeRuntimeCommands(commands, variant) {
|
||||
if (variant === 'docker') return commands;
|
||||
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
|
||||
if (_envState.env !== 'venv' || !envPath) return commands;
|
||||
const py = _shellQuote(`${envPath}/bin/python3`);
|
||||
return commands.map(cmd => String(cmd || '').replace(/^python(\s+-m\s+pip\b)/, `${py}$1`));
|
||||
}
|
||||
function _recipeDisplayText(commands, variant) {
|
||||
const runtimeCommands = _recipeRuntimeCommands(commands, variant);
|
||||
if (variant === 'docker') return commands.join('\n');
|
||||
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
|
||||
const activate = envPath
|
||||
? `source ${envPath}${envPath.endsWith('/bin/activate') ? '' : '/bin/activate'}`
|
||||
: '# (activate your venv first)';
|
||||
return [activate, ...commands].join('\n');
|
||||
return [activate, ...runtimeCommands].join('\n');
|
||||
}
|
||||
|
||||
// Per-backend recipe panel (model picker + commands + Copy/Run).
|
||||
@@ -1224,18 +1277,19 @@ async function _fetchDependencies() {
|
||||
const initial = pickRecipe(backend, '') || candidates[0];
|
||||
const initialVariant = RECIPE_DEFAULT_VARIANT;
|
||||
const initialCmds = recipeCommands(initial, initialVariant);
|
||||
const initialRuntimeCmds = _recipeRuntimeCommands(initialCmds, initialVariant);
|
||||
const rightActive = initialVariant === 'docker' ? ' mode-right' : '';
|
||||
return `<div class="cookbook-dep-recipe-panel" data-dep-recipe-panel="${esc(backend)}" data-dep-recipe-active-variant="${esc(initialVariant)}" style="display:none;margin:-4px 0 8px;padding:8px 12px 10px;background:rgba(0,0,0,0.04);border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<span style="font-size:11px;opacity:0.75;flex-shrink:0;">Serving which model?</span>
|
||||
<select class="settings-select cookbook-dep-recipe-pick" data-dep-recipe-pick="${esc(backend)}" style="flex:1;font-size:11px;padding:3px 6px;">${opts}</select>
|
||||
<div class="mode-toggle${rightActive}" data-dep-recipe-variants="${esc(backend)}" style="flex-shrink:0;">
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip/uv</button>
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip</button>
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'docker' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="docker" aria-pressed="${initialVariant === 'docker'}">Docker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:relative;">
|
||||
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
|
||||
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialRuntimeCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
|
||||
<button type="button" id="recipe-copy-${esc(backend)}" class="cookbook-dep-recipe-copy" data-dep-recipe-copy="${esc(backend)}" title="Copy" aria-label="Copy" style="position:absolute;top:6px;right:6px;padding:3px 5px;background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;display:inline-flex;align-items:center;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:6px;">
|
||||
@@ -1244,18 +1298,99 @@ async function _fetchDependencies() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _rowsHtml = (items) => items.map(_depRow).join('');
|
||||
const _sectionHeader = (title, note) =>
|
||||
`<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>`;
|
||||
const _section = (title, note, items) =>
|
||||
items.length
|
||||
? `<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>` + items.map(_depRow).join('')
|
||||
items.length ? _sectionHeader(title, note) + _rowsHtml(items) : '';
|
||||
const _pkgOrder = {
|
||||
System: ['tmux', 'docker'],
|
||||
Tools: ['hf_transfer'],
|
||||
LLM: ['llama_cpp', 'sglang', 'vllm', 'mlx_lm'],
|
||||
Image: ['diffusers', 'krea_diffusers', 'transformers', 'sam_mask', 'mflux', 'boogu_image_mlx', 'mlx_vlm'],
|
||||
};
|
||||
const _sortDeps = (items, category) => {
|
||||
const order = _pkgOrder[category] || [];
|
||||
return [...items].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
const ar = ai === -1 ? 999 : ai;
|
||||
const br = bi === -1 ? 999 : bi;
|
||||
return ar - br || String(a.name || '').localeCompare(String(b.name || ''));
|
||||
});
|
||||
};
|
||||
const _serverDepsHtml = (items) => {
|
||||
const byCat = new Map();
|
||||
for (const item of items) {
|
||||
const cat = item.category || 'Other';
|
||||
if (!byCat.has(cat)) byCat.set(cat, []);
|
||||
byCat.get(cat).push(item);
|
||||
}
|
||||
const parts = [];
|
||||
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
|
||||
for (const cat of order) {
|
||||
const catItems = _sortDeps(byCat.get(cat) || [], cat);
|
||||
if (!catItems.length) continue;
|
||||
if (cat === 'Image') {
|
||||
const mlxNames = new Set(['mflux', 'boogu_image_mlx', 'mlx_vlm']);
|
||||
const general = catItems.filter(p => !mlxNames.has(p.name));
|
||||
const mlx = catItems.filter(p => mlxNames.has(p.name));
|
||||
parts.push(_sectionHeader('Image', 'Diffusers and shared image tooling.'));
|
||||
if (general.length) parts.push(_rowsHtml(general));
|
||||
if (mlx.length) {
|
||||
parts.push(
|
||||
`<div class="cookbook-dep-subgroup">`
|
||||
+ `<div class="cookbook-dep-subgroup-title"><span>MLX image runtimes</span><em>Apple Silicon only</em></div>`
|
||||
+ _rowsHtml(mlx)
|
||||
+ `</div>`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const note = cat === 'System'
|
||||
? 'OS tools needed for background tasks.'
|
||||
: cat === 'LLM'
|
||||
? 'Text model serving engines and download helpers.'
|
||||
: cat === 'Tools'
|
||||
? 'Browser and assistant utilities.'
|
||||
: '';
|
||||
parts.push(_section(cat, note, catItems));
|
||||
}
|
||||
return parts.join('');
|
||||
};
|
||||
const _appDepsHtml = (items) => {
|
||||
if (!items.length) return '';
|
||||
const byCat = new Map();
|
||||
for (const item of items) {
|
||||
const cat = item.category || 'Other';
|
||||
if (!byCat.has(cat)) byCat.set(cat, []);
|
||||
byCat.get(cat).push(item);
|
||||
}
|
||||
const parts = [_sectionHeader('Odysseus app', 'Run inside the Odysseus app itself.')];
|
||||
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
|
||||
for (const cat of order) {
|
||||
const catItems = _sortDeps(byCat.get(cat) || [], cat);
|
||||
if (!catItems.length) continue;
|
||||
const note = cat === 'LLM'
|
||||
? 'Local app model helpers.'
|
||||
: cat === 'Image'
|
||||
? 'Editor image tools.'
|
||||
: cat === 'Tools'
|
||||
? 'Browser and assistant utilities.'
|
||||
: '';
|
||||
parts.push(_section(cat, note, catItems));
|
||||
}
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
const _viewingRemote = !!(_dsel && _dsel.value && _dsel.value !== 'local');
|
||||
const _appDeps = pkgs.filter(p => p.target === 'local');
|
||||
const _serverDeps = pkgs.filter(p => p.target !== 'local');
|
||||
const _visibleDep = (p) => p.applicable !== false || p.installed || (p.kind === 'system' && p.name !== 'APFEL');
|
||||
const _appDeps = pkgs.filter(p => p.target === 'local' && _visibleDep(p));
|
||||
const _serverDeps = pkgs.filter(p => p.target !== 'local' && _visibleDep(p));
|
||||
|
||||
list.innerHTML = [
|
||||
_viewingRemote ? '' : _section('Odysseus app', 'Run inside the Odysseus app itself.', _appDeps),
|
||||
_section('Server', 'Run on the server chosen above (Local, or a remote box over SSH).', _serverDeps),
|
||||
_viewingRemote ? '' : _appDepsHtml(_appDeps),
|
||||
_serverDepsHtml(_serverDeps),
|
||||
].join('');
|
||||
|
||||
// Shared install/update routine — used by the Install button and the
|
||||
@@ -1275,8 +1410,11 @@ async function _fetchDependencies() {
|
||||
}
|
||||
}
|
||||
const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local');
|
||||
const targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
|
||||
let targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
|
||||
const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || '');
|
||||
if (!isLocalOnly && targetEnvPath && (!targetEnv || targetEnv === 'none')) {
|
||||
targetEnv = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(targetEnvPath) ? 'venv' : targetEnv;
|
||||
}
|
||||
const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || '');
|
||||
const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || '');
|
||||
// Always go through `python -m pip` so the leading token is `python`
|
||||
@@ -1300,7 +1438,14 @@ async function _fetchDependencies() {
|
||||
} else {
|
||||
_py = 'python3';
|
||||
}
|
||||
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
|
||||
const pipArgs = String(pipName || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(_shellQuote)
|
||||
.join(' ');
|
||||
const depTaskId = String(pkgName || pipName || 'dependency').trim().replace(/\s+/g, '_');
|
||||
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} ${pipArgs}`;
|
||||
let envPrefix = '';
|
||||
if (_isWindows()) {
|
||||
if (targetEnv === 'venv' && targetEnvPath) {
|
||||
@@ -1318,7 +1463,7 @@ async function _fetchDependencies() {
|
||||
}
|
||||
try {
|
||||
const reqBody = {
|
||||
repo_id: pipName,
|
||||
repo_id: depTaskId,
|
||||
cmd: cmd,
|
||||
remote_host: targetRemoteHost || undefined,
|
||||
ssh_port: _getPort(targetRemoteHost) || undefined,
|
||||
@@ -1347,7 +1492,7 @@ async function _fetchDependencies() {
|
||||
}
|
||||
// _dep flags this as a pip dependency/driver install (not a servable
|
||||
// model) so the running-task card doesn't offer a "Serve →" button.
|
||||
const payload = { repo_id: pipName, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
|
||||
const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
|
||||
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
|
||||
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
|
||||
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
|
||||
@@ -1422,9 +1567,8 @@ async function _fetchDependencies() {
|
||||
});
|
||||
});
|
||||
|
||||
// Inline command-box "Copy" buttons — one per row that has a
|
||||
// resolved per-target install command. Same string surfaces here
|
||||
// and in the toast/diagnosis so the user always sees one answer.
|
||||
// Inline command "Copy" buttons, currently used by targeted recipe
|
||||
// repair panels such as the llama.cpp CUDA wheel reinstall.
|
||||
list.querySelectorAll('.cookbook-dep-cmd-copy').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1443,12 +1587,6 @@ async function _fetchDependencies() {
|
||||
const names = (btn.dataset.depSysdeps || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!names.length) return;
|
||||
const isLocal = btn.dataset.depTarget === 'local';
|
||||
// Pull the per-target install command from the sibling box on
|
||||
// the same row, so failure toasts surface the SAME line the
|
||||
// user already sees inline. No duplicated formatting logic.
|
||||
const _row = btn.closest('.cookbook-dep-row');
|
||||
const _cmdBox = _row?.querySelector('.cookbook-dep-install-cmd');
|
||||
const _resolvedCmd = _cmdBox?.dataset.depCmd || '';
|
||||
// Mirror _installDep: the Dependencies tab has its own server
|
||||
// picker that can override _envState. Apply it before reading
|
||||
// remoteHost, otherwise the install silently runs on the wrong
|
||||
@@ -1481,18 +1619,10 @@ async function _fetchDependencies() {
|
||||
try { await _fetchDependencies(); } catch {}
|
||||
} else {
|
||||
const reason = data.error || data.detail || `HTTP ${res.status}`;
|
||||
// Append the per-target install command (if we already know it
|
||||
// from the row) so the user can copy-paste it without leaving
|
||||
// the toast. Otherwise just surface the error.
|
||||
const _suffix = _resolvedCmd ? `\n\nRun on ${targetLabel}: ${_resolvedCmd}` : '';
|
||||
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300) + _suffix, {
|
||||
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300), {
|
||||
duration: 25000,
|
||||
action: _resolvedCmd ? 'Copy command' : 'OK',
|
||||
onAction: async () => {
|
||||
if (_resolvedCmd) {
|
||||
try { await navigator.clipboard.writeText(_resolvedCmd); } catch {}
|
||||
}
|
||||
},
|
||||
action: 'OK',
|
||||
onAction: () => {},
|
||||
});
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
@@ -1532,17 +1662,18 @@ async function _fetchDependencies() {
|
||||
const sel = panel.querySelector('[data-dep-recipe-pick]');
|
||||
const recipe = pickRecipe(backend, (sel && sel.value) || '');
|
||||
const cmds = recipeCommands(recipe, variant);
|
||||
const runtimeCmds = _recipeRuntimeCommands(cmds, variant);
|
||||
const pre = panel.querySelector('[data-dep-recipe-cmds]');
|
||||
if (pre) {
|
||||
pre.textContent = _recipeDisplayText(cmds, variant);
|
||||
pre.dataset.depRecipeInstall = cmds.join('\n');
|
||||
pre.dataset.depRecipeInstall = runtimeCmds.join('\n');
|
||||
}
|
||||
}
|
||||
// Model select: pickRecipe matches the model id against the catalog.
|
||||
list.querySelectorAll('[data-dep-recipe-pick]').forEach(sel => {
|
||||
sel.addEventListener('change', () => _refreshRecipePre(sel.dataset.depRecipePick));
|
||||
});
|
||||
// Variant toggle (Pip/uv vs Docker): mirrors the agent/chat mode-toggle
|
||||
// Variant toggle (Pip vs Docker): mirrors the agent/chat mode-toggle
|
||||
// pattern — buttons get .active, container gets .mode-right when the
|
||||
// right slot is selected so the sliding pill animates over.
|
||||
list.querySelectorAll('[data-dep-recipe-variant]').forEach(btn => {
|
||||
@@ -1595,16 +1726,26 @@ async function _fetchDependencies() {
|
||||
// displayed source line is for the user's reading; env_prefix
|
||||
// handles it for the actual run.
|
||||
const installRaw = pre.dataset.depRecipeInstall || pre.textContent;
|
||||
const cmd = installRaw.split('\n').map(s => s.trim()).filter(Boolean).join(' && ');
|
||||
const depsSel = document.getElementById('hwfit-deps-server');
|
||||
if (depsSel) _applyServerSelection(depsSel.value);
|
||||
const targetHost = _envState.remoteHost || 'local';
|
||||
const inferredVenv = _envState.envPath && (!_envState.env || _envState.env === 'none')
|
||||
&& /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(_envState.envPath);
|
||||
const recipeEnv = inferredVenv ? 'venv' : _envState.env;
|
||||
const recipePy = (recipeEnv === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '').replace(/\/bin\/activate$/i, '')}/bin/python3`
|
||||
: '';
|
||||
const cmd = installRaw.split('\n').map(s => {
|
||||
let line = s.trim();
|
||||
if (recipePy) line = line.replace(/^python(?:3)?\s+-m\s+pip\b/, `${recipePy} -m pip`);
|
||||
return line;
|
||||
}).filter(Boolean).join(' && ');
|
||||
// Build env_prefix from the configured envPath (matches _installDep).
|
||||
let envPrefix = '';
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
if (recipeEnv === 'venv' && _envState.envPath) {
|
||||
const p = _envState.envPath;
|
||||
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
} else if (recipeEnv === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
const reqBody = {
|
||||
@@ -2013,6 +2154,27 @@ function _wireTabEvents(body) {
|
||||
hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget);
|
||||
}
|
||||
|
||||
const hwAdvancedBtn = document.getElementById('hwfit-advanced-btn');
|
||||
const hwAdvancedPanel = document.getElementById('hwfit-advanced-panel');
|
||||
if (hwAdvancedBtn && hwAdvancedPanel && !hwAdvancedBtn.dataset.bound) {
|
||||
hwAdvancedBtn.dataset.bound = '1';
|
||||
const setAdvancedOpen = (open) => {
|
||||
hwAdvancedPanel.classList.toggle('hidden', !open);
|
||||
hwAdvancedBtn.classList.toggle('active', open);
|
||||
hwAdvancedBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
hwAdvancedBtn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setAdvancedOpen(hwAdvancedPanel.classList.contains('hidden'));
|
||||
});
|
||||
hwAdvancedPanel.addEventListener('click', (ev) => ev.stopPropagation());
|
||||
document.addEventListener('click', () => setAdvancedOpen(false));
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') setAdvancedOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
|
||||
if (editDirsLink) {
|
||||
editDirsLink.addEventListener('click', () => {
|
||||
@@ -2926,15 +3088,39 @@ function _renderRecipes() {
|
||||
html += '</div>';
|
||||
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Scans your hardware for what models you can run. Hardware is cached; hit the scan button to re-probe after changing GPUs.</p>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:9px;">';
|
||||
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="height:28px;">';
|
||||
html += '<option value="general" selected>Standard</option>';
|
||||
// Image tab removed — text→image gen is gone from this build (only inpaint
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
html += '<option value="multimodal">Vision</option></select>';
|
||||
// Search moved next to the Type filter so the two primary picks
|
||||
// (what category + free text) sit together; the more advanced
|
||||
// levers (Engine / Quant / Context) live to the right.
|
||||
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
|
||||
html += _buildServerOpts(false);
|
||||
html += '</select>';
|
||||
// Keep the main scan toolbar light: server + free-text search. Advanced
|
||||
// levers (Engine / Quant / Context) live behind the cog beside Refresh.
|
||||
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
|
||||
html += '<span class="hwfit-usecase-wrap">';
|
||||
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="display:none;height:28px;">';
|
||||
html += '<option value="general" selected>Standard</option>';
|
||||
html += '<option value="multimodal">Vision</option>';
|
||||
html += '<option value="image_gen">Image</option></select>';
|
||||
html += '<button type="button" class="cookbook-field-input hwfit-usecase-btn" data-hwfit-usecase-btn aria-haspopup="listbox" aria-expanded="false" title="Model type">';
|
||||
html += '<span class="hwfit-usecase-btn-icon" data-hwfit-usecase-icon aria-hidden="true"></span>';
|
||||
html += '<span class="hwfit-usecase-btn-label" data-hwfit-usecase-label>Standard</span>';
|
||||
html += '<svg class="hwfit-usecase-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>';
|
||||
html += '</button>';
|
||||
html += '<div class="hwfit-usecase-menu" data-hwfit-usecase-menu role="listbox" hidden></div>';
|
||||
html += '</span>';
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-advanced-btn" id="hwfit-advanced-btn" title="Scan settings" aria-label="Scan settings" aria-expanded="false" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg></button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-5px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
html += '<option value="newest" selected>Latest</option>';
|
||||
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
|
||||
html += '<option value="speed">Speed</option><option value="params">Params</option>';
|
||||
html += '<option value="context">Context</option></select>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-advanced-panel hidden" id="hwfit-advanced-panel" aria-label="Scan settings">';
|
||||
html += '<span class="hwfit-engine-wrap">';
|
||||
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="display:none;" title="Filter by serving engine">';
|
||||
html += '<option value="">Engine</option>';
|
||||
@@ -2971,21 +3157,6 @@ function _renderRecipes() {
|
||||
html += '<span>Context</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
|
||||
html += '<output id="hwfit-context-label">50k</output></label>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
|
||||
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
|
||||
html += _buildServerOpts(false);
|
||||
html += '</select>';
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
html += '<option value="newest" selected>Latest</option>';
|
||||
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
|
||||
html += '<option value="speed">Speed</option><option value="params">Params</option>';
|
||||
html += '<option value="context">Context</option></select>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-manual-panel hidden" id="hwfit-manual-panel">';
|
||||
html += '<span class="hwfit-manual-note" style="font-size:10px;opacity:0.6;width:100%;margin-bottom:2px;">Simulator — these values REPLACE detected hardware.</span>';
|
||||
html += '<select class="hwfit-manual-mode"><option value="gpu">GPU</option><option value="ram">RAM</option></select>';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis
|
||||
import { registerMenuDismiss } from './escMenuStack.js';
|
||||
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
||||
import { portOf, nextFreePort } from './cookbookPorts.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
// Human-friendly badge label for a task's internal status. Avoids surfacing
|
||||
// the word "error" in the sidebar — a server the user stopped or one that
|
||||
@@ -20,6 +21,18 @@ function _statusLabel(status, type) {
|
||||
return status || '';
|
||||
}
|
||||
|
||||
function _downloadBadgeText(progress) {
|
||||
const raw = String(progress || '').trim();
|
||||
if (!raw) return 'downloading';
|
||||
const pct = raw.match(/(\d+)%/);
|
||||
if (pct) return pct[0];
|
||||
if (/^(?:Downloading|Fetching|Resuming)\s+'[^']+'\s+to\s+'[^']+/i.test(raw)
|
||||
|| /^Downloading\s*\(incomplete\b/i.test(raw)) {
|
||||
return 'downloading';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Single source of truth for what a task's status badge shows + its style class.
|
||||
// Crucially, a serve task that's still coming up shows its live phase
|
||||
// ("loading 45%", "warming up", …) rather than the generic "running" — they're
|
||||
@@ -29,8 +42,7 @@ function _statusLabel(status, type) {
|
||||
function _taskBadge(task) {
|
||||
if (task._unreachable && task.status === 'running') return { text: 'unreachable', cls: 'cookbook-task-error' };
|
||||
if (task.type === 'download' && task.status === 'running') {
|
||||
const progress = String(task.progress || '').trim();
|
||||
return { text: progress || _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' };
|
||||
return { text: _downloadBadgeText(task.progress), cls: 'cookbook-task-downloading' };
|
||||
}
|
||||
if (task.type === 'serve' && task.status === 'running' && task.progress) {
|
||||
// Same green "running" pill — just with dynamic phase text, so it doesn't
|
||||
@@ -61,9 +73,12 @@ function _downloadNameFromPayload(name, payload) {
|
||||
const rawName = String(name || '').trim();
|
||||
// Defensive: failed/restarted downloads can inherit the wrapper executable
|
||||
// name if older state was saved from a command preview. The row title should
|
||||
// always be the model/repo, never "bash" or "python".
|
||||
// always be the model/repo, never "bash", "python", or a live HF progress
|
||||
// line like "Downloading 'vae/...' to '/mnt/...".
|
||||
const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName);
|
||||
const base = (!rawName || looksLikeLauncher)
|
||||
const looksLikeProgressLine = /^(?:Downloading|Fetching|Resuming)\s+'[^']+'\s+to\s+'[^']+/i.test(rawName)
|
||||
|| /^Downloading\s*\(incomplete\b/i.test(rawName);
|
||||
const base = (!rawName || looksLikeLauncher || looksLikeProgressLine)
|
||||
? String(payload?.repo_id || payload?.repo || '').split('/').pop()
|
||||
: rawName;
|
||||
const include = payload?.include || '';
|
||||
@@ -636,6 +651,11 @@ function _appendPinnedServeModel(fd, task) {
|
||||
if (expected) fd.append('pinned_models', expected);
|
||||
}
|
||||
|
||||
function _isImageServeTask(task) {
|
||||
const cmd = String(task?.payload?._cmd || '');
|
||||
return cmd.includes('diffusion_server') || cmd.includes('mlx_image_server');
|
||||
}
|
||||
|
||||
// ── Download queue — runs one at a time per server ──
|
||||
|
||||
function _processQueue() {
|
||||
@@ -1274,7 +1294,7 @@ function _autoSaveWorkingConfig(task) {
|
||||
if (task._autoSaved) return;
|
||||
const cmd = task.payload._cmd;
|
||||
// Diffusion/image servers aren't vLLM presets — skip them.
|
||||
if (cmd.includes('diffusion_server')) { task._autoSaved = true; return; }
|
||||
if (cmd.includes('diffusion_server') || cmd.includes('mlx_image_server')) { task._autoSaved = true; return; }
|
||||
const model = task.payload.repo_id || task.name;
|
||||
const presets = _loadPresets();
|
||||
const existing = presets.find(p => p.cmd === cmd);
|
||||
@@ -1754,13 +1774,14 @@ function _parseServeCmdToFields(cmd) {
|
||||
const ex = (re) => { const m = cmd.match(re); return m ? m[1] : ''; };
|
||||
const fields = {
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp'
|
||||
: cmd.includes('mlx_image_server') ? 'mlx_image'
|
||||
: cmd.includes('mlx_lm.server') ? 'mlx'
|
||||
: cmd.includes('diffusion_server') ? 'diffusers'
|
||||
: cmd.includes('sglang') ? 'sglang'
|
||||
: cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
port: ex(/--port\s+(\d+)/) || '8000',
|
||||
tp: ex(/--tensor-parallel-size\s+(\d+)/) || '1',
|
||||
ctx: ex(/--max-model-len\s+(\d+)/) || ex(/--n_ctx\s+(\d+)/) || ex(/-c\s+(\d+)/) || '8192',
|
||||
ctx: ex(/--max-model-len\s+(\d+)/) || ex(/--context-length\s+(\d+)/) || ex(/--max-tokens\s+(\d+)/) || ex(/--n_ctx\s+(\d+)/) || ex(/-c\s+(\d+)/) || '8192',
|
||||
gpu_mem: ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
|
||||
swap: ex(/--swap-space\s+(\d+)/) || '',
|
||||
dtype: ex(/--dtype\s+(\w+)/) || 'auto',
|
||||
@@ -1796,7 +1817,7 @@ function _serveCmdNeedsGpuPreflight(cmd, repo) {
|
||||
const c = String(cmd || '').toLowerCase();
|
||||
const r = String(repo || '').toLowerCase();
|
||||
if (!c || /gpu-cleanup|sglang-kernel|mlx-lm|pip\s+install|python\d*\s+-m\s+pip/.test(`${r} ${c}`)) return false;
|
||||
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
|
||||
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|mlx_image_server\.py|diffusion_server\.py|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
|
||||
}
|
||||
|
||||
function _selectedGpuIndexes(gpus) {
|
||||
@@ -1900,6 +1921,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
|
||||
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || '');
|
||||
const _replaceTaskId = fields?._replaceTaskId || '';
|
||||
const _launchAnyway = !!targetMeta?.launchAnyway;
|
||||
if (_replaceTaskId) {
|
||||
try {
|
||||
const _old = _loadTasks().find(t => t.sessionId === _replaceTaskId);
|
||||
@@ -1917,7 +1939,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
// servers on one port, so re-serving (or retrying) should stop & remove the
|
||||
// old one instead of leaving a dead duplicate behind. (The retry buttons
|
||||
// already removed their own task, so this is a no-op for them.)
|
||||
try {
|
||||
if (!_launchAnyway) try {
|
||||
const _pm = cmd.match(/--port[=\s]+(\d+)/) || cmd.match(/(?:^|\s)-p[=\s]+(\d+)/);
|
||||
const _newPort = _pm ? _pm[1] : '';
|
||||
if (_newPort) {
|
||||
@@ -2374,14 +2396,15 @@ export function _renderRunningTab() {
|
||||
const _bdg = _taskBadge(task);
|
||||
const _bdgTitle = (task._unreachable && task.status === 'running') ? ' title="Server not responding — it may have crashed"' : '';
|
||||
const displayName = _taskDisplayName(task);
|
||||
const logoName = task.type === 'download' ? (task.payload?.repo_id || task.name) : task.name;
|
||||
el.innerHTML = `
|
||||
<div class="cookbook-task-header">
|
||||
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(displayName)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(logoName)}${esc(displayName)}</span>
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span>${_canLaunchDownloadedTask(task) ? '<button type="button" class="cookbook-task-serve-btn" title="Open in Launch"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Launch</span></button>' : ''}<span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
<button type="button" class="cookbook-task-start-now" title="Start this queued download now" style="display:${(task.type === 'download' && task.status === 'queued') ? '' : 'none'}"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="8 5 19 12 8 19 8 5"/></svg><span>start now</span></button>
|
||||
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
|
||||
<button class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
<button type="button" class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
</div>
|
||||
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span>${(task.type === 'download') ? `<span class="cookbook-task-dldir" title="Download destination" style="font-size:9px;color:var(--fg-muted);font-family:'Fira Code',monospace;opacity:0.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch;">Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}</span>` : ''}</div>
|
||||
<div class="cookbook-output-wrap cookbook-task-collapsible${(_mobileCollapseDefault && !_shouldAutoExpandTaskOutput(task)) ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
|
||||
@@ -2572,8 +2595,10 @@ export function _renderRunningTab() {
|
||||
el.addEventListener('touchmove', _lpMove, { passive: true });
|
||||
el.addEventListener('touchend', _lpCancel, { passive: true });
|
||||
el.addEventListener('touchcancel', _lpCancel, { passive: true });
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
let _lastTouchMenuOpenAt = 0;
|
||||
const _openTaskMenu = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.type === 'click' && Date.now() - _lastTouchMenuOpenAt < 550) return;
|
||||
const existing = document.querySelector('.cookbook-task-dropdown');
|
||||
if (existing && existing._anchor === menuBtn) {
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss();
|
||||
@@ -2646,7 +2671,7 @@ export function _renderRunningTab() {
|
||||
fd.append('name', task.name);
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||
if (task.payload?._cmd?.includes('diffusion_server')) fd.append('model_type', 'image');
|
||||
if (_isImageServeTask(task)) fd.append('model_type', 'image');
|
||||
const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (res.ok) {
|
||||
task._endpointAdded = true;
|
||||
@@ -2764,6 +2789,7 @@ export function _renderRunningTab() {
|
||||
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
dropdown.style.position = 'fixed';
|
||||
dropdown.style.zIndex = String(topPortalZ());
|
||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -2811,7 +2837,13 @@ export function _renderRunningTab() {
|
||||
window.visualViewport?.addEventListener('scroll', scrollClose);
|
||||
}, 0);
|
||||
_unreg = registerMenuDismiss(_cleanup);
|
||||
});
|
||||
};
|
||||
menuBtn.addEventListener('click', _openTaskMenu);
|
||||
menuBtn.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
_lastTouchMenuOpenAt = Date.now();
|
||||
_openTaskMenu(e);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Hidden action buttons for menu dispatch
|
||||
@@ -3619,7 +3651,7 @@ async function _reconnectTask(el, task) {
|
||||
if (_ex && _ex.id && !(_ex.models || []).length) _probeEndpointUntilOnline(_ex.id, host, port);
|
||||
return null;
|
||||
}
|
||||
const _isDiffusion = task.payload?._cmd?.includes('diffusion_server');
|
||||
const _isDiffusion = _isImageServeTask(task);
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', baseUrl);
|
||||
fd.append('name', task.name);
|
||||
@@ -4236,7 +4268,6 @@ async function _pollBackgroundStatus() {
|
||||
for (const t of readyServes) {
|
||||
const localTasks = _loadTasks();
|
||||
const localTask = localTasks.find(lt => lt.sessionId === t.session_id);
|
||||
if (localTask && localTask._endpointAdded) continue;
|
||||
|
||||
let host = _connectHostFromRemote(localTask?.remoteHost || t.remote);
|
||||
const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/)
|
||||
@@ -4249,9 +4280,9 @@ async function _pollBackgroundStatus() {
|
||||
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
|
||||
if (endpoint) ({ host, port, baseUrl } = endpoint);
|
||||
}
|
||||
const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server');
|
||||
const _isDiffusion = _isImageServeTask(localTask);
|
||||
|
||||
_updateTask(t.session_id, { _serveReady: true, _endpointAdded: true });
|
||||
_updateTask(t.session_id, { _serveReady: true });
|
||||
if (localTask) _autoSaveWorkingConfig(localTask); // remember working settings (modal may be closed)
|
||||
|
||||
// Auto-detect function-calling support from the serve cmd.
|
||||
@@ -4273,6 +4304,7 @@ async function _pollBackgroundStatus() {
|
||||
_markServeEndpointMismatch(taskForMatch, existing, host, port);
|
||||
return null;
|
||||
}
|
||||
_updateTask(t.session_id, { _endpointAdded: true });
|
||||
// Already registered — but it may be showing offline because
|
||||
// it was added while the server was still warming. Kick a
|
||||
// re-probe so it flips online without manual toggle.
|
||||
@@ -4291,6 +4323,7 @@ async function _pollBackgroundStatus() {
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res && res.ok) {
|
||||
_updateTask(t.session_id, { _endpointAdded: true });
|
||||
uiModule.showToast(`Model endpoint added: ${host}:${port}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// A just-started server often can't answer the 1s add-time
|
||||
@@ -4328,12 +4361,8 @@ async function _pollBackgroundStatus() {
|
||||
statusEl.textContent = 'cooking';
|
||||
}
|
||||
} else {
|
||||
var _dlProgress = '';
|
||||
if (t.progress) {
|
||||
var _pctMatch = t.progress.match(/(\d+)%/);
|
||||
_dlProgress = _pctMatch ? ` ${_pctMatch[0]}` : '';
|
||||
}
|
||||
statusEl.textContent = `downloading${_dlProgress}`;
|
||||
const _dlText = _downloadBadgeText(t.progress);
|
||||
statusEl.textContent = _dlText === 'downloading' ? 'downloading' : `downloading ${_dlText}`;
|
||||
}
|
||||
statusEl.style.display = '';
|
||||
} else if (errorTasks.length > 0) {
|
||||
|
||||
+357
-52
@@ -46,7 +46,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
|
||||
let _cachedAllModels = [];
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v3_ltx_video';
|
||||
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
@@ -54,6 +54,47 @@ function _normalizeCookbookModelDir(dir) {
|
||||
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
|
||||
}
|
||||
|
||||
function _serveCmdPort(cmd) {
|
||||
const s = String(cmd || '');
|
||||
const m = s.match(/--port[=\s]+(\d+)/)
|
||||
|| s.match(/(?:^|\s)-p[=\s]+(\d+)/)
|
||||
|| s.match(/OLLAMA_HOST=[^:\s]+:(\d+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function _replaceServeCmdPort(cmd, port) {
|
||||
const s = String(cmd || '');
|
||||
const p = String(port || '').trim();
|
||||
if (!s || !p) return s;
|
||||
if (/(^|\s)--port=\d+/.test(s)) return s.replace(/(^|\s)--port=\d+/, `$1--port=${p}`);
|
||||
if (/(^|\s)--port\s+\d+/.test(s)) return s.replace(/(^|\s)--port\s+\d+/, `$1--port ${p}`);
|
||||
if (/(^|\s)-p=\d+/.test(s)) return s.replace(/(^|\s)-p=\d+/, `$1-p=${p}`);
|
||||
if (/(^|\s)-p\s+\d+/.test(s)) return s.replace(/(^|\s)-p\s+\d+/, `$1-p ${p}`);
|
||||
if (/OLLAMA_HOST=([^:\s]+):\d+/.test(s)) return s.replace(/OLLAMA_HOST=([^:\s]+):\d+/, `OLLAMA_HOST=$1:${p}`);
|
||||
return `${s} --port ${p}`;
|
||||
}
|
||||
|
||||
function _nextServeLaunchPort(currentPort, runningMod, host, serverKey) {
|
||||
const used = new Set();
|
||||
try {
|
||||
for (const t of (runningMod?._loadTasks ? runningMod._loadTasks() : [])) {
|
||||
if (!t || t.type !== 'serve') continue;
|
||||
if (!(t.status === 'queued' || t.status === 'running' || t.status === 'ready' || t._serveReady)) continue;
|
||||
const sameTarget = ((t.remoteHost || '') === (host || ''))
|
||||
|| ((t.remoteServerKey || '') === (serverKey || ''));
|
||||
if (!sameTarget) continue;
|
||||
const tp = runningMod?._taskPort ? runningMod._taskPort(t) : _serveCmdPort(t.payload?._cmd || t.cmd || '');
|
||||
const n = parseInt(tp, 10);
|
||||
if (Number.isFinite(n) && n > 0) used.add(n);
|
||||
}
|
||||
} catch {}
|
||||
const start = parseInt(currentPort || '8000', 10) || 8000;
|
||||
used.add(start);
|
||||
let next = Math.max(1, start + 1);
|
||||
while (used.has(next)) next += 1;
|
||||
return String(next);
|
||||
}
|
||||
|
||||
function _readCachedModelScan(sig) {
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
|
||||
@@ -611,6 +652,56 @@ function _estimateLlamaContextFit(model, fields, modelCtxMax, modelWeightsGb = 0
|
||||
};
|
||||
}
|
||||
|
||||
function _estimateMlxContextFit(model, fields, modelCtxMax, modelWeightsGb = 0, fitSystem = null) {
|
||||
const sys = fitSystem || _hwfitCache?.system || {};
|
||||
const modelMax = Math.max(1024, _modelContextMaxForServe(model, modelCtxMax));
|
||||
const modelGb = _modelSizeGb(model, modelWeightsGb);
|
||||
const availableRamGb = Number(sys.available_ram_gb) || 0;
|
||||
const totalRamGb = Number(sys.total_ram_gb) || 0;
|
||||
const unifiedPoolGb = Math.max(availableRamGb, totalRamGb > 0 ? totalRamGb * 0.75 : 0);
|
||||
if (!unifiedPoolGb) {
|
||||
return {
|
||||
ctx: Math.min(modelMax, 32768),
|
||||
needsHardwareScan: true,
|
||||
reason: 'scan Apple memory first; using model limit fallback',
|
||||
};
|
||||
}
|
||||
if (!modelGb) {
|
||||
return {
|
||||
ctx: Math.min(modelMax, 32768),
|
||||
needsModelSize: true,
|
||||
reason: 'model weight size unknown; using MLX fallback',
|
||||
};
|
||||
}
|
||||
|
||||
const usableGb = Math.max(1, unifiedPoolGb - Math.max(4.0, unifiedPoolGb * 0.10));
|
||||
const freeForKv = usableGb - modelGb;
|
||||
const name = `${model?.repo_id || ''} ${model?.name || ''} ${model?.quant || ''}`.toLowerCase();
|
||||
const totalParams = _parseParamsB(name) || Math.max(1, modelGb / 0.58);
|
||||
const activeMatch = name.match(/\ba(\d+(?:\.\d+)?)b\b/);
|
||||
const activeParams = activeMatch ? parseFloat(activeMatch[1]) : (/moe|minimax|deepseek|mixtral|kimi-k2/.test(name) ? Math.min(totalParams, 32) : totalParams);
|
||||
// MLX uses unified memory. This is intentionally conservative because the
|
||||
// server exposes max generation tokens, not a hard prefill context length.
|
||||
const kvGbPerToken = Math.max(0.00002, 0.0000065 * activeParams);
|
||||
if (freeForKv <= 0) {
|
||||
return {
|
||||
ctx: Math.min(modelMax, 2048),
|
||||
modelGb,
|
||||
kvGbPerToken,
|
||||
reason: `model ${modelGb.toFixed(1)}G exceeds usable unified memory ${usableGb.toFixed(1)}G before KV`,
|
||||
};
|
||||
}
|
||||
const raw = Math.floor(freeForKv / kvGbPerToken);
|
||||
const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024);
|
||||
const ctx = Math.min(modelMax, rounded);
|
||||
return {
|
||||
ctx,
|
||||
modelGb,
|
||||
kvGbPerToken,
|
||||
reason: `MLX --max-tokens from unified memory (${freeForKv.toFixed(1)}G free)`,
|
||||
};
|
||||
}
|
||||
|
||||
function _selectedServeTarget(panel) {
|
||||
const select = panel?.querySelector?.('#hwfit-server-select')
|
||||
|| document.getElementById('hwfit-server-select')
|
||||
@@ -629,11 +720,11 @@ function _selectedServeTarget(panel) {
|
||||
}
|
||||
}
|
||||
const typedVenv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || '';
|
||||
// For remote targets the server profile is authoritative. Otherwise a stale
|
||||
// venv typed/loaded for another host can leak into this launch, e.g. a Linux
|
||||
// /home/... Python path being used on an Apple Silicon MLX server.
|
||||
// A venv typed in the serve panel is a per-launch/per-model override and must
|
||||
// win over the server default. _buildServeCmd still drops obviously wrong
|
||||
// platform paths, so stale Linux/macOS paths do not leak across hosts.
|
||||
const venv = host
|
||||
? (server?.envPath || typedVenv || '')
|
||||
? (typedVenv || server?.envPath || '')
|
||||
: (typedVenv || server?.envPath || _envState.envPath || '');
|
||||
const label = host
|
||||
? (server?.name ? `${server.name} (${host})` : host)
|
||||
@@ -660,19 +751,79 @@ function _backendChoicesForTarget(target) {
|
||||
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
|
||||
}
|
||||
return _isMetal()
|
||||
? [['mlx','MLX'],['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['diffusers','Diffusers']];
|
||||
? [['mlx','MLX'],['mlx_image','MLX Image'],['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['mlx_image','MLX Image'],['diffusers','Diffusers']];
|
||||
}
|
||||
|
||||
async function _fetchServeRuntimePackage(panel, backend) {
|
||||
function _dependencyPkgForServeBackend(backend, modelName = '') {
|
||||
const nm = String(modelName || '').toLowerCase();
|
||||
if (backend === 'mlx_image' && nm.includes('boogu')) return 'boogu_image_mlx';
|
||||
if (backend === 'mlx_image' && (nm.includes('mi-gan') || nm.includes('migan') || nm.includes('lama'))) return 'mlx_lama_swift';
|
||||
if (backend === 'mlx_image' && nm.includes('ddcolor')) return 'mlx_ddcolor_swift';
|
||||
if (backend === 'diffusers' && nm.includes('krea')) return 'krea_diffusers';
|
||||
const packageByBackend = {
|
||||
vllm: 'vllm',
|
||||
sglang: 'sglang',
|
||||
llamacpp: 'llama_cpp',
|
||||
mlx: 'mlx_lm',
|
||||
mlx_image: 'mflux',
|
||||
diffusers: 'diffusers',
|
||||
};
|
||||
const packageName = packageByBackend[backend];
|
||||
return packageByBackend[backend];
|
||||
}
|
||||
|
||||
function _looksLikeAdapterModel(m) {
|
||||
const repo = String(m?.repo_id || '');
|
||||
const n = repo.toLowerCase();
|
||||
return !!(
|
||||
m?.is_adapter
|
||||
|| /\b(lora|adapter|peft|qlora)\b/i.test(n)
|
||||
|| /(?:^|[-_/])(lora|adapter|peft|qlora)(?:[-_/]|$)/i.test(repo)
|
||||
|| /control[-_]?lora|diffusion[-_]?lora/i.test(repo)
|
||||
);
|
||||
}
|
||||
|
||||
function _cachedAdapterModels(currentRepo = '') {
|
||||
const current = String(currentRepo || '');
|
||||
return (_cachedAllModels || [])
|
||||
.filter(m => m && m.status === 'ready' && m.repo_id && m.repo_id !== current)
|
||||
.sort((a, b) => String(a.repo_id || '').localeCompare(String(b.repo_id || '')));
|
||||
}
|
||||
|
||||
function _cachedAdapterSelectHtml(kind, currentRepo = '') {
|
||||
const adapters = _cachedAdapterModels(currentRepo);
|
||||
const cls = kind === 'vllm_lora_modules'
|
||||
? 'hwfit-backend-vllm'
|
||||
: kind === 'diff_lora'
|
||||
? 'hwfit-backend-diffusers'
|
||||
: kind === 'mlx_lora_paths'
|
||||
? 'hwfit-backend-mlx_image'
|
||||
: '';
|
||||
if (!adapters.length) {
|
||||
return `<label class="hwfit-cached-adapter-label ${cls}" style="grid-column:1 / -1;">Cached adapter <select class="hwfit-cached-adapter-select" data-adapter-kind="${esc(kind)}" disabled style="height:30px;width:100%;background:var(--bg);color:var(--fg-muted);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;opacity:0.75;"><option value="">No cached adapters found</option></select></label>`;
|
||||
}
|
||||
const opts = adapters.map(m => {
|
||||
const repo = String(m.repo_id || '');
|
||||
const value = m.is_local_dir && m.path
|
||||
? `${String(m.path || '').replace(/\/+$/, '')}/${repo}`
|
||||
: repo;
|
||||
const short = repo.split('/').pop() || repo;
|
||||
return `<option value="${esc(value)}">${esc(short)}</option>`;
|
||||
}).join('');
|
||||
return `<label class="hwfit-cached-adapter-label ${cls}" style="grid-column:1 / -1;">Cached adapter <select class="hwfit-cached-adapter-select" data-adapter-kind="${esc(kind)}" style="height:30px;width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;"><option value="">Choose cached adapter…</option>${opts}</select></label>`;
|
||||
}
|
||||
|
||||
async function _fetchServeRuntimePackage(panel, backend) {
|
||||
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
|
||||
const packageByBackend = {
|
||||
vllm: 'vllm',
|
||||
sglang: 'sglang',
|
||||
llamacpp: 'llama_cpp',
|
||||
mlx: 'mlx_lm',
|
||||
mlx_image: 'mflux',
|
||||
diffusers: 'diffusers',
|
||||
};
|
||||
const packageName = _dependencyPkgForServeBackend(backend, repo) || packageByBackend[backend];
|
||||
if (!packageName) return null;
|
||||
const target = _selectedServeTarget(panel);
|
||||
const params = new URLSearchParams();
|
||||
@@ -681,6 +832,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
if (target.port) params.set('ssh_port', target.port);
|
||||
if (target.venv) params.set('venv', target.venv);
|
||||
}
|
||||
if (repo) params.set('model_hint', repo);
|
||||
const res = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -689,7 +841,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
}
|
||||
|
||||
function _runtimeNoteText(backend, pkg, target) {
|
||||
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', diffusers: 'Diffusers' };
|
||||
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', mlx_image: 'MLX Image', diffusers: 'Diffusers' };
|
||||
const label = labels[backend] || backend;
|
||||
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
|
||||
const note = pkg.status_note || pkg.update_note || '';
|
||||
@@ -750,6 +902,14 @@ function _isActivelyServing(repoId) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function _isIncompleteCachedModel(model) {
|
||||
return !!model && (
|
||||
model.status === 'stalled'
|
||||
|| model.has_incomplete
|
||||
|| (model.status === 'downloading' && !_isActivelyDownloading(model.repo_id))
|
||||
);
|
||||
}
|
||||
|
||||
function _formatGgufSize(bytes) {
|
||||
const n = Number(bytes || 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return '';
|
||||
@@ -964,6 +1124,7 @@ function _rerenderCachedModels() {
|
||||
let html = '';
|
||||
let visibleCount = 0;
|
||||
for (const m of allModels) {
|
||||
if (m.is_adapter && !m.is_diffusion && !m.is_video) continue;
|
||||
if (activeTag && m._tag !== activeTag) continue;
|
||||
if (searchVal && !(m.repo_id || '').toLowerCase().includes(searchVal)) continue;
|
||||
visibleCount++;
|
||||
@@ -1085,7 +1246,9 @@ function _rerenderCachedModels() {
|
||||
const items = [];
|
||||
items.push({ label: _favNow ? 'Unfavorite' : 'Favorite', icon: _favIco, action: 'favorite' });
|
||||
if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' });
|
||||
if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' });
|
||||
if (m && (m.status === 'downloading' || m.status === 'stalled' || m.has_incomplete)) {
|
||||
items.push({ label: 'Resume download', icon: _retryIco, action: 'retry' });
|
||||
}
|
||||
if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' });
|
||||
items.push({ label: 'Select', icon: _selectIco, action: 'select' });
|
||||
items.push({ label: 'Delete', icon: _deleteIco, action: 'delete', danger: true });
|
||||
@@ -1102,7 +1265,7 @@ function _rerenderCachedModels() {
|
||||
_rerenderCachedModels();
|
||||
}
|
||||
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
|
||||
else if (opt.action === 'retry') _retryCachedModel(repo, m);
|
||||
else if (opt.action === 'retry') _promptResumeIncompleteModel(m, item);
|
||||
else if (opt.action === 'schedule') {
|
||||
// Same entry point as the ^ button next to Launch — let
|
||||
// cookbookSchedule.js handle it. Expand the panel first
|
||||
@@ -1171,7 +1334,7 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Wire click on card to expand serve panel
|
||||
list.querySelectorAll('.memory-item[data-repo]').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
item.addEventListener('click', async (e) => {
|
||||
if (e.target.closest('a, .hwfit-cached-menu-btn, .memory-item-btn, .hwfit-serve-panel')) return;
|
||||
if (document.getElementById('hwfit-cache-select')?.classList.contains('active')) return;
|
||||
const repo = item.dataset.repo;
|
||||
@@ -1179,9 +1342,15 @@ function _rerenderCachedModels() {
|
||||
const m = allModels.find(x => x.repo_id === repo);
|
||||
if (!m) return;
|
||||
if (m.status !== 'ready') {
|
||||
if (m.status === 'downloading' && !_isActivelyDownloading(m.repo_id)) {
|
||||
if (m.status === 'downloading' && _isActivelyDownloading(m.repo_id)) {
|
||||
uiModule.showToast?.(`${(m.name || m.repo_id || 'Model').split('/').pop()} is still downloading.`);
|
||||
} else if (_isIncompleteCachedModel(m)) {
|
||||
await _promptResumeIncompleteModel(m, item);
|
||||
} else if (m.status === 'downloading') {
|
||||
uiModule.showToast?.('Refreshing cached model status…');
|
||||
_fetchCachedModels(true);
|
||||
} else {
|
||||
uiModule.showToast?.(`${(m.name || m.repo_id || 'Model').split('/').pop()} is not ready yet.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1245,11 +1414,12 @@ function _rerenderCachedModels() {
|
||||
const _backendChoices = _backendChoicesForTarget(_serveTarget);
|
||||
const _allowedBackends = new Set(_backendChoices.map(([v]) => v));
|
||||
const detectedBackend = _detectBackend(m).backend;
|
||||
let defaultBackend = (_repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
const _imageBackend = detectedBackend === 'mlx_image' || detectedBackend === 'diffusers';
|
||||
let defaultBackend = (!_imageBackend && _repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
? ss.backend
|
||||
: detectedBackend;
|
||||
if (!_allowedBackends.has(defaultBackend)) defaultBackend = _backendChoices[0]?.[0] || detectedBackend;
|
||||
const savedMatchesBackend = _repoForcedBackend || (ss.backend || 'vllm') === detectedBackend;
|
||||
const savedMatchesBackend = !_imageBackend && (_repoForcedBackend || (ss.backend || 'vllm') === detectedBackend);
|
||||
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
|
||||
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', _isMiniMaxMSeries ? '8' : '1');
|
||||
const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.());
|
||||
@@ -1452,6 +1622,8 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Attention','vLLM VLLM_ATTENTION_BACKEND. auto = vLLM picks (often FLASHINFER, which JITs and can fail on old nvcc). FLASH_ATTN skips the JIT entirely.')}<select class="hwfit-sf" data-field="vllm_attn_backend" style="height:32px;">${vllmAttnBackendOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Block Size','vLLM --block-size. Controls KV-cache block granularity. Leave blank for runtime default; some sparse-attention or custom runtimes need a specific value.')}<input type="text" class="hwfit-sf" data-field="vllm_block_size" value="${esc(svm('vllm_block_size', _isMiniMaxM3 ? '128' : ''))}" placeholder="auto" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','vLLM CPU swap space in GB. Blank/off omits the flag; enter a positive number only for older vLLM runtimes that support --swap-space.')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
|
||||
panelHtml += _cachedAdapterSelectHtml('vllm_lora_modules', repo);
|
||||
panelHtml += `<label class="hwfit-backend-vllm" style="grid-column:1 / -1;">${_l('LoRA Modules','vLLM LoRA modules, one per line or comma-separated, using name=path. Adds --enable-lora --lora-modules.')}<input type="text" class="hwfit-sf" data-field="vllm_lora_modules" value="${esc(sv('vllm_lora_modules', ''))}" placeholder="style=/path/to/lora or style=org/repo" style="width:100%;" /></label>`;
|
||||
{
|
||||
const _envPresetDefault = _isMiniMaxM3 ? 'minimax_m3_cuda' : '';
|
||||
const _envPresetVal = svm('vllm_env_preset', _envPresetDefault);
|
||||
@@ -1471,15 +1643,36 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang hwfit-extra-env-label">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). The Env Preset above covers the usual MiniMax M3 values; use this for additional overrides.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(svm('extra_env', sv('extra_env','')))}" placeholder="NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2b: Diffusers settings
|
||||
const diffDefaultNegative = 'low quality, blurry, out of focus, deformed, distorted, disfigured, unfinished, smudged, watermark, artifacts';
|
||||
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
|
||||
const deviceMapOpts = ['balanced','auto','sequential'].map(d => `<option value="${d}"${sv('diff_device_map','balanced')===d?' selected':''}>${d}</option>`).join('');
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-diff-settings-row">`;
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-settings-row">`;
|
||||
panelHtml += `<label>Dtype${_h('Precision. bfloat16 recommended for Flux, float16 for SD')} <select class="hwfit-sf" data-field="diff_dtype">${diffDtypeOpts}</select></label>`;
|
||||
panelHtml += `<label>Device Map${_h('How to place model on GPUs. balanced = split evenly')} <select class="hwfit-sf" data-field="diff_device_map">${deviceMapOpts}</select></label>`;
|
||||
panelHtml += `<label>Steps${_h('Default inference steps. More = better quality, slower')} <input type="text" class="hwfit-sf" data-field="diff_steps" value="${esc(sv('diff_steps', ''))}" placeholder="auto" /></label>`;
|
||||
panelHtml += `<label>Steps${_h('Default inference steps. More = better quality, slower. Override with the model card recommendation when needed.')} <input type="text" class="hwfit-sf" data-field="diff_steps" value="${esc(sv('diff_steps', '20'))}" placeholder="20" /></label>`;
|
||||
panelHtml += `<label>Guidance${_h('Classifier-free guidance scale. Override with the model card recommended value when available.')} <input type="text" class="hwfit-sf" data-field="diff_guidance_scale" value="${esc(sv('diff_guidance_scale', '3.5'))}" placeholder="3.5" /></label>`;
|
||||
panelHtml += `<label>Width${_h('Default output width')} <input type="text" class="hwfit-sf" data-field="diff_width" value="${esc(sv('diff_width', ''))}" placeholder="1024" /></label>`;
|
||||
panelHtml += `<label>Height${_h('Default output height')} <input type="text" class="hwfit-sf" data-field="diff_height" value="${esc(sv('diff_height', ''))}" placeholder="1024" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-adapters-row">`;
|
||||
panelHtml += _cachedAdapterSelectHtml('diff_lora', repo);
|
||||
panelHtml += `<label class="hwfit-backend-diffusers" style="grid-column:1 / -1;">Negative${_h('Default negative prompt. Adds --negative-prompt for pipelines that support it. Edit or clear this per model.')} <input type="text" class="hwfit-sf" data-field="diff_negative_prompt" value="${esc(sv('diff_negative_prompt', diffDefaultNegative))}" placeholder="${esc(diffDefaultNegative)}" style="width:100%;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-diffusers" style="grid-column:1 / -1;">LoRA${_h('Diffusers LoRA file/path(s), comma or newline separated. Adds --lora.')} <input type="text" class="hwfit-sf" data-field="diff_lora" value="${esc(sv('diff_lora', ''))}" placeholder="/path/adapter.safetensors or org/repo" style="width:100%;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-diffusers">Scale${_h('Diffusers LoRA scale. Adds --lora-scale.')} <input type="text" class="hwfit-sf" data-field="diff_lora_scale" value="${esc(sv('diff_lora_scale', ''))}" placeholder="1.0" /></label>`;
|
||||
{
|
||||
const _mlxBase = sv('mlx_base_model', '');
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image">Base model${_h('Optional runtime base model/family override from the model card. Adds --base-model. This is not a LoRA/adaptor.')} <input type="text" class="hwfit-sf" data-field="mlx_base_model" value="${esc(_mlxBase)}" placeholder="auto" /></label>`;
|
||||
}
|
||||
{
|
||||
const _mlxStyle = sv('mlx_lora_style', '');
|
||||
const _styleOpts = ['', 'couple', 'font', 'home', 'identity', 'illustration', 'portrait', 'ppt', 'sandstorm', 'sparklers', 'storyboard']
|
||||
.map(v => `<option value="${v}"${_mlxStyle === v ? ' selected' : ''}>${v || 'none'}</option>`).join('');
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image">Style${_h('mflux built-in LoRA style. Adds --lora-style.')} <select class="hwfit-sf" data-field="mlx_lora_style">${_styleOpts}</select></label>`;
|
||||
}
|
||||
panelHtml += _cachedAdapterSelectHtml('mlx_lora_paths', repo);
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image" style="grid-column:1 / -1;">LoRA Paths${_h('mflux LoRA paths/repos, comma or newline separated. Adds --lora-paths.')} <input type="text" class="hwfit-sf" data-field="mlx_lora_paths" value="${esc(sv('mlx_lora_paths', ''))}" placeholder="org/lora or repo:file.safetensors" style="width:100%;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image" style="grid-column:1 / -1;">LoRA Scales${_h('mflux LoRA scales matching paths. Space/comma/newline separated. Adds --lora-scales.')} <input type="text" class="hwfit-sf" data-field="mlx_lora_scales" value="${esc(sv('mlx_lora_scales', ''))}" placeholder="0.8, 1.0" style="width:100%;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3: Advanced toggles for vLLM/SGLang. Several concepts overlap,
|
||||
// but the actual flags differ; keep labels backend-neutral where a
|
||||
// shared checkbox maps to different runtime flags.
|
||||
@@ -1581,11 +1774,11 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="llama_speculative_mtp"${sv('llama_speculative_mtp',false)?' checked':''} /> MTP Spec${_h('llama.cpp native MTP speculative decoding: --spec-type draft-mtp. Requires a GGUF with MTP heads.')} <input type="number" class="hwfit-sf hwfit-spec-tokens hwfit-spec-tokens-bare" data-field="llama_spec_tokens" value="${esc(sv('llama_spec_tokens', '3'))}" min="1" max="10" title="--spec-draft-n-max" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3b: Checkboxes (diffusers)
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers hwfit-diff-checks-row">`;
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-checks-row">`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_offload"${sv('diff_offload',false)?' checked':''} /> CPU Offload${_h('Offload parts of model to CPU RAM to save VRAM. Slower but fits larger models')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_attention_slicing"${sv('diff_attention_slicing',false)?' checked':''} /> Attention Slicing${_h('Slice attention computation to reduce peak VRAM. Slower')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_vae_slicing"${sv('diff_vae_slicing',false)?' checked':''} /> VAE Slicing${_h('Process VAE in slices. Reduces VRAM for high-res images')}</label>`;
|
||||
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers hwfit-diff-harmonize-row">`;
|
||||
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-harmonize-row">`;
|
||||
panelHtml += `<label>Harmonize GPU${_h('Separate GPU for img2img/harmonize. Leave empty to use same GPU')}<input type="text" class="hwfit-sf" data-field="diff_harmonize_gpu" value="${esc(sv('diff_harmonize_gpu', ''))}" placeholder="auto" style="width:50px;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Model-specific optimizations. The checks row always renders for the
|
||||
@@ -1747,6 +1940,8 @@ function _rerenderCachedModels() {
|
||||
let fit = null;
|
||||
if (backend === 'vllm' || backend === 'sglang') {
|
||||
fit = _estimateVllmContextFit(m, f, panel._modelCtxMax, panel._modelWeightsGb, panel._fitSystem);
|
||||
} else if (backend === 'mlx') {
|
||||
fit = _estimateMlxContextFit(m, f, panel._modelCtxMax, panel._modelWeightsGb, panel._fitSystem);
|
||||
} else if (backend === 'llamacpp' || backend === 'ollama') {
|
||||
const ggufGb = _selectedGgufSizeGb(m, f.gguf_file);
|
||||
fit = _estimateLlamaContextFit(m, f, panel._modelCtxMax, ggufGb || panel._modelWeightsGb, panel._fitSystem, panel._contextProfileData);
|
||||
@@ -1772,6 +1967,8 @@ function _rerenderCachedModels() {
|
||||
: 'selected GPU memory';
|
||||
_ctxAutoNote.title = backend === 'llamacpp' || backend === 'ollama'
|
||||
? `Estimated from scanned GGUF/model size, trained context limit, and ${_llamaMemoryLabel} for llama.cpp KV cache.`
|
||||
: backend === 'mlx'
|
||||
? `MLX-LM server does not expose a context-length flag; Cookbook maps this estimate to MLX --max-tokens using scanned unified memory and model size.`
|
||||
: `Estimated from model size, selected GPU VRAM, GPU utilization, TP, and KV dtype.`;
|
||||
}
|
||||
if (apply && _ctxEl0.dataset.autoCtx === '1') {
|
||||
@@ -1951,6 +2148,7 @@ function _rerenderCachedModels() {
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mlx_image: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="M21 15l-5-5L5 20"/><path d="M17.5 4v4M15.5 6h4"/></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
@@ -2064,7 +2262,7 @@ function _rerenderCachedModels() {
|
||||
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
|
||||
const noteText = note.querySelector('.hwfit-serve-runtime-text');
|
||||
const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; };
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'diffusers'].includes(backend)) {
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'mlx_image', 'diffusers'].includes(backend)) {
|
||||
note.style.display = 'none';
|
||||
_writeNote('');
|
||||
return;
|
||||
@@ -2104,8 +2302,8 @@ function _rerenderCachedModels() {
|
||||
// recipe panel for this backend so the user has one click
|
||||
// to the fix instead of hunting for the right row.
|
||||
if (noteText) {
|
||||
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', diffusers: 'diffusers' }[backend]);
|
||||
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
|
||||
const pkgName = pkg?.name || _dependencyPkgForServeBackend(backend, repo);
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
link.textContent = ' Install in Dependencies →';
|
||||
@@ -2159,15 +2357,16 @@ function _rerenderCachedModels() {
|
||||
});
|
||||
} else {
|
||||
const fields = {
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_image_server') ? 'mlx_image' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
port: _ex(/--port\s+(\d+)/) || '8000',
|
||||
tp: _ex(/--tensor-parallel-size\s+(\d+)/) || '1',
|
||||
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
|
||||
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--context-length\s+(\d+)/) || _ex(/--max-tokens\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
|
||||
gpu_mem: _ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
|
||||
swap: _ex(/--swap-space\s+(\d+)/) || '',
|
||||
dtype: _ex(/--dtype\s+(\w+)/) || 'auto',
|
||||
vllm_kv_cache_dtype: _ex(/--kv-cache-dtype\s+([\w.-]+)/) || 'auto',
|
||||
max_seqs: _ex(/--max-num-seqs\s+(\d+)/) || '',
|
||||
vllm_lora_modules: _ex(/--lora-modules\s+(.+?)(?:\s+--|$)/) || '',
|
||||
cache_type: _ex(/(?:--cache-type-k|-ctk)\s+(\S+)/) || '',
|
||||
llama_fit: _ex(/(?:--fit|-fit)\s+(on|off)/) || '',
|
||||
llama_split_mode: _ex(/(?:--split-mode|-sm)\s+(none|layer|row|tensor)/) || '',
|
||||
@@ -2177,6 +2376,14 @@ function _rerenderCachedModels() {
|
||||
llama_batch_size: _ex(/(?:--batch-size|-b)\s+(\d+)/) || '',
|
||||
llama_ubatch_size: _ex(/(?:--ubatch-size|-ub)\s+(\d+)/) || '',
|
||||
llama_spec_tokens: _ex(/--spec-draft-n-max\s+(\d+)/) || '3',
|
||||
diff_lora: (_ex(/--lora\s+'([^']*)'/) || _ex(/--lora\s+(\S+)/) || '').replace(/,/g, '\n'),
|
||||
diff_lora_scale: _ex(/--lora-scale\s+([\d.]+)/) || '',
|
||||
diff_guidance_scale: _ex(/--guidance-scale\s+([\d.]+)/) || '',
|
||||
diff_negative_prompt: _ex(/--negative-prompt\s+'([^']*)'/) || _ex(/--negative-prompt\s+(.+?)(?:\s+--|$)/) || '',
|
||||
mlx_base_model: _ex(/--base-model\s+'?([^'\s]+)'?/) || '',
|
||||
mlx_lora_style: _ex(/--lora-style\s+'?([^'\s]+)'?/) || '',
|
||||
mlx_lora_paths: (_ex(/--lora-paths\s+(.+?)(?:\s+--|$)/) || '').replace(/'\s+'/g, '\n').replace(/^'|'$/g, ''),
|
||||
mlx_lora_scales: (_ex(/--lora-scales\s+(.+?)(?:\s+--|$)/) || '').replace(/'\s+'/g, '\n').replace(/^'|'$/g, ''),
|
||||
venv: p.envPath || '',
|
||||
};
|
||||
const checks = {
|
||||
@@ -2268,8 +2475,9 @@ function _rerenderCachedModels() {
|
||||
const presets = _loadPresets();
|
||||
const modelSlots = _presetsForModel(presets, repo);
|
||||
// Compute the current launch command first so we can detect a no-op save.
|
||||
updateCmd();
|
||||
const cmd = panel._cmd;
|
||||
if (!_cmdManuallyEdited) updateCmd();
|
||||
const cmdBox = panel.querySelector('.hwfit-serve-cmd');
|
||||
const cmd = _normalizeServeCmdForLaunch((_cmdManuallyEdited && cmdBox) ? cmdBox.value : panel._cmd);
|
||||
// Already saved? If an existing preset for this model has the identical
|
||||
// launch command, don't make a duplicate — tell the user via a popup.
|
||||
const _norm = s => String(s || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -2289,6 +2497,8 @@ function _rerenderCachedModels() {
|
||||
if (el.type === 'checkbox') fields[el.dataset.field] = el.checked;
|
||||
else fields[el.dataset.field] = el.value;
|
||||
});
|
||||
if (_cmdManuallyEdited) fields._manual_cmd = cmd;
|
||||
else delete fields._manual_cmd;
|
||||
presets.push(_redactServeStateForStorage({ name: shortName, model: repo, cmd, remoteHost: host, port: fields.port || '8000', label, fields }));
|
||||
_savePresets(presets);
|
||||
uiModule.showToast(`Saved "${label}"`);
|
||||
@@ -2513,6 +2723,7 @@ function _rerenderCachedModels() {
|
||||
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
||||
const r = _launchMoreBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = String(topPortalZ());
|
||||
menu.style.right = (window.innerWidth - r.right) + 'px';
|
||||
document.body.appendChild(menu);
|
||||
{
|
||||
@@ -2559,6 +2770,7 @@ function _rerenderCachedModels() {
|
||||
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
||||
const r = _splitArrow.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = String(topPortalZ());
|
||||
menu.style.right = (window.innerWidth - r.right) + 'px';
|
||||
document.body.appendChild(menu);
|
||||
// Default open BELOW, but if there's no room (esp. on mobile where
|
||||
@@ -2916,6 +3128,31 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.hwfit-cached-adapter-select').forEach(sel => {
|
||||
sel.addEventListener('change', () => {
|
||||
const repoId = String(sel.value || '').trim();
|
||||
if (!repoId) return;
|
||||
const kind = sel.dataset.adapterKind || '';
|
||||
const target = panel.querySelector(`[data-field="${kind}"]`);
|
||||
if (!target) return;
|
||||
const current = String(target.value || '').trim();
|
||||
let next = repoId;
|
||||
if (kind === 'vllm_lora_modules') {
|
||||
const name = repoId.split('/').pop().replace(/[^A-Za-z0-9_.-]+/g, '_') || 'adapter';
|
||||
next = `${name}=${repoId}`;
|
||||
}
|
||||
if (current) {
|
||||
const lines = current.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
if (!lines.includes(next)) lines.push(next);
|
||||
target.value = lines.join('\n');
|
||||
} else {
|
||||
target.value = next;
|
||||
}
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
updateCmd();
|
||||
});
|
||||
});
|
||||
// llama.cpp CPU/GPU/Unified mode-toggle wiring. Clicking a mode
|
||||
// flips the .active classes + marker class (so the sliding
|
||||
// pill matches Agent/Chat), updates the hidden data-field input,
|
||||
@@ -2994,6 +3231,14 @@ function _rerenderCachedModels() {
|
||||
// Track manual edits
|
||||
let _cmdManuallyEdited = false;
|
||||
const _cmdTextarea = panel.querySelector('.hwfit-serve-cmd');
|
||||
const _savedManualCmd = String(svm('_manual_cmd', '') || '').trim();
|
||||
if (_cmdTextarea && _savedManualCmd) {
|
||||
panel._cmd = _savedManualCmd;
|
||||
_cmdTextarea.value = _formatServeCmdPreview(_savedManualCmd);
|
||||
_cmdTextarea.style.height = 'auto';
|
||||
_cmdTextarea.style.height = _cmdTextarea.scrollHeight + 'px';
|
||||
_cmdManuallyEdited = true;
|
||||
}
|
||||
if (_cmdTextarea) _cmdTextarea.addEventListener('input', () => { _cmdManuallyEdited = true; });
|
||||
|
||||
// Cancel button — collapses the serve config panel (same effect as
|
||||
@@ -3078,8 +3323,9 @@ function _rerenderCachedModels() {
|
||||
// all whitespace to single spaces before launch — same effect as the
|
||||
// user manually re-flowing the textarea, no behavior change.
|
||||
const _rawLaunchCmd = (_cmdManuallyEdited && _cmdTextarea) ? _cmdTextarea.value : panel._cmd;
|
||||
const launchCmd = _normalizeServeCmdForLaunch(_rawLaunchCmd);
|
||||
let launchCmd = _normalizeServeCmdForLaunch(_rawLaunchCmd);
|
||||
const serveState = {};
|
||||
let launchAnyway = false;
|
||||
panel.querySelectorAll('.hwfit-sf').forEach(el => {
|
||||
if (el.type === 'checkbox') serveState[el.dataset.field] = el.checked;
|
||||
else serveState[el.dataset.field] = el.value;
|
||||
@@ -3091,7 +3337,7 @@ function _rerenderCachedModels() {
|
||||
uiModule.showToast('Vision is checked, but no mmproj projector is in the launch command. Refresh cached models after downloading mmproj, or add --mmproj manually.', 8000);
|
||||
return;
|
||||
}
|
||||
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
|
||||
if ((serveState.backend === 'diffusers' || serveState.backend === 'mlx_image') && _remoteWindowsDiffusersUnsupported(launchTarget)) {
|
||||
_restoreLaunchBtn();
|
||||
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
|
||||
return;
|
||||
@@ -3113,18 +3359,32 @@ function _rerenderCachedModels() {
|
||||
// Only block when the new model's port genuinely collides with
|
||||
// a running serve. Different ports coexist fine (issue #4507).
|
||||
if (_active.length) {
|
||||
const _newPort = (launchCmd.match(/--port[=\s]+(\d+)/) || [])[1] || '';
|
||||
const _newPort = _serveCmdPort(launchCmd);
|
||||
const _clashing = _newPort
|
||||
? _active.filter(t => _runningMod._taskPort(t) === _newPort)
|
||||
: _active;
|
||||
if (_clashing.length) {
|
||||
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _portNote = _newPort ? ` on port ${_newPort}` : '';
|
||||
const _ok = await window.styledConfirm(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it and launch this one?`,
|
||||
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
|
||||
const _choice = await window.styledConfirm(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it first, or launch anyway?`,
|
||||
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', alternateText: 'Launch anyway', cancelText: 'Cancel' },
|
||||
);
|
||||
if (!_ok) { _restoreLaunchBtn(); return; }
|
||||
if (!_choice) { _restoreLaunchBtn(); return; }
|
||||
if (_choice === 'alternate') {
|
||||
launchAnyway = true;
|
||||
const _oldPort = _newPort || _serveCmdPort(launchCmd);
|
||||
const _nextPort = _nextServeLaunchPort(_oldPort, _runningMod, _hostStr, _serverKeyStr);
|
||||
if (_oldPort && _nextPort && _nextPort !== _oldPort) {
|
||||
launchCmd = _replaceServeCmdPort(launchCmd, _nextPort);
|
||||
serveState.port = _nextPort;
|
||||
panel._cmd = launchCmd;
|
||||
if (_cmdTextarea) _cmdTextarea.value = launchCmd;
|
||||
uiModule.showToast(`Launching anyway on port ${_nextPort}. Existing serve stays on ${_oldPort}.`, 7000);
|
||||
} else {
|
||||
uiModule.showToast('Launching anyway. If the port is already occupied, the new serve may fail.', 6000);
|
||||
}
|
||||
} else {
|
||||
// Kill each clashing serve; prefer the rendered Stop button so
|
||||
// endpoint cleanup + Ollama unload run normally. Fall back to
|
||||
// a raw tmux kill when the Active tab isn't in the DOM.
|
||||
@@ -3146,6 +3406,7 @@ function _rerenderCachedModels() {
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
|
||||
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
|
||||
@@ -3387,6 +3648,8 @@ function _rerenderCachedModels() {
|
||||
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
|
||||
const _saved = { ...serveState, _forceBackend: true };
|
||||
delete _saved._replaceTaskId;
|
||||
if (_cmdManuallyEdited) _saved._manual_cmd = launchCmd;
|
||||
else delete _saved._manual_cmd;
|
||||
byRepo[repo] = _saved;
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(_redactServeStateForStorage({ _byRepo: byRepo, _lastUsed: _saved })));
|
||||
} catch {}
|
||||
@@ -3446,7 +3709,7 @@ function _rerenderCachedModels() {
|
||||
// Pass the exact form values so the running task can be re-opened
|
||||
// in the Serve panel pre-filled with these settings (Edit button).
|
||||
const taskDisplayName = _serveTaskDisplayName(shortName, m, serveState);
|
||||
await _launchServeTask(taskDisplayName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName });
|
||||
await _launchServeTask(taskDisplayName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName, launchAnyway });
|
||||
});
|
||||
} finally {
|
||||
_envState.env = origEnv;
|
||||
@@ -3481,8 +3744,12 @@ function _rerenderCachedModels() {
|
||||
// Resolve the host the cached list was scanned from, mirroring
|
||||
// _fetchCachedModels — so a delete targets the SAME machine the model
|
||||
// actually lives on, not just the globally-selected serve host.
|
||||
function _resolveCacheHost() {
|
||||
function _serverFromCacheSelection() {
|
||||
let host = _envState.remoteHost || '';
|
||||
let server = host
|
||||
? (_envState.servers || []).find(s => s.host === host) || null
|
||||
: ((_envState.servers || []).find(s => !s.host || s.host === 'local') || null);
|
||||
let key = '';
|
||||
const cacheSrv = document.getElementById('hwfit-cache-server');
|
||||
|
||||
function _serverByCacheValue(val) {
|
||||
@@ -3496,14 +3763,25 @@ function _resolveCacheHost() {
|
||||
|
||||
if (cacheSrv) {
|
||||
const val = cacheSrv.value;
|
||||
key = val || '';
|
||||
if (val === 'local') {
|
||||
host = '';
|
||||
server = (_envState.servers || []).find(s => !s.host || s.host === 'local') || null;
|
||||
} else {
|
||||
const s = _serverByCacheValue(val);
|
||||
if (s) host = s.host;
|
||||
if (s) {
|
||||
host = s.host || '';
|
||||
server = s;
|
||||
key = _serverKey?.(s) || val || '';
|
||||
}
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
return { host, server, key };
|
||||
}
|
||||
|
||||
function _resolveCacheHost() {
|
||||
return _serverFromCacheSelection().host || '';
|
||||
}
|
||||
|
||||
async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = null) {
|
||||
@@ -3628,27 +3906,55 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
|
||||
}
|
||||
}
|
||||
|
||||
async function _promptResumeIncompleteModel(m, itemEl = null) {
|
||||
const repo = m?.repo_id || itemEl?.dataset?.repo || '';
|
||||
if (!repo) return;
|
||||
const short = (m?.name || repo).split('/').pop();
|
||||
if (_isActivelyDownloading(repo)) {
|
||||
uiModule.showToast?.(`${short} is already downloading.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await uiModule.styledConfirm(
|
||||
`${short} is not finished downloading.\n\nResume the download on the selected cache server?`,
|
||||
{ confirmText: 'Resume download', cancelText: 'Not now' }
|
||||
);
|
||||
if (!ok) return;
|
||||
uiModule.showToast?.(`Resuming ${short}…`);
|
||||
_retryCachedModel(repo, m);
|
||||
}
|
||||
|
||||
function _retryCachedModel(repo, m) {
|
||||
const payload = { repo_id: repo };
|
||||
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
|
||||
const _target = _selectedServeTarget(document.getElementById('cookbook-modal') || document);
|
||||
const _target = _serverFromCacheSelection();
|
||||
const srv = _target.server || {};
|
||||
if (_target.host) {
|
||||
payload.remote_host = _target.host;
|
||||
if (_target.port) payload.ssh_port = _target.port;
|
||||
if (_target.key && _target.key !== 'local') payload.remote_server_key = _target.key;
|
||||
if (srv.name) payload.remote_server_name = srv.name;
|
||||
const port = srv.port || _getPort(_target.host);
|
||||
if (port) payload.ssh_port = port;
|
||||
}
|
||||
if (_target.platform) payload.platform = _target.platform;
|
||||
if (_isWindows()) {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
payload.env_prefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
payload.env_prefix = 'conda activate ' + _psQuote(_envState.envPath);
|
||||
const platform = _target.host ? (srv.platform || _getPlatform(_target.host) || '') : (_envState.hostPlatform || '');
|
||||
if (platform) payload.platform = platform;
|
||||
const env = _target.host ? (srv.env || 'none') : (_envState.env || 'none');
|
||||
const envPath = _target.host ? (srv.envPath || '') : (_envState.envPath || '');
|
||||
const downloadDir = srv.downloadDir || (m?.is_local_dir && m?.path ? m.path : '');
|
||||
if (downloadDir) payload.local_dir = _normalizeCookbookModelDir(downloadDir);
|
||||
payload.disable_hf_transfer = true;
|
||||
if (platform === 'windows') {
|
||||
if (env === 'venv' && envPath) {
|
||||
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (env === 'conda' && envPath) {
|
||||
payload.env_prefix = 'conda activate ' + _psQuote(envPath);
|
||||
}
|
||||
} else {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
const p = _envState.envPath;
|
||||
if (env === 'venv' && envPath) {
|
||||
const p = envPath;
|
||||
payload.env_prefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
} else if (env === 'conda' && envPath) {
|
||||
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(envPath);
|
||||
}
|
||||
}
|
||||
_retryDownload((m?.name || repo).split('/').pop(), payload);
|
||||
@@ -3753,8 +4059,7 @@ function _renderCachedModelsData(list, data, host) {
|
||||
const _families = [
|
||||
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
|
||||
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
|
||||
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
|
||||
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
|
||||
[/minimax/i, 'minimax'], [/glm/i, 'glm'],
|
||||
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
|
||||
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
|
||||
];
|
||||
@@ -3762,7 +4067,7 @@ function _renderCachedModelsData(list, data, host) {
|
||||
const n = (m.repo_id || '').toLowerCase();
|
||||
let tag = 'other';
|
||||
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
|
||||
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
|
||||
else if (m.is_diffusion || m.is_video || m.is_image_gen || /(?:^|[-_/])(diffusion|image)(?:[-_/]|$)/i.test(n)) tag = 'image';
|
||||
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
|
||||
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
|
||||
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
|
||||
|
||||
+313
-151
@@ -123,6 +123,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
let activeDocId = null; // currently visible doc
|
||||
let _lastSessionId = ''; // session context for "+" button
|
||||
const docs = new Map(); // docId -> { id, title, language, content, version, sessionId }
|
||||
let _emailSendInFlight = false;
|
||||
|
||||
const _docOpenKey = (sessionId) => 'odysseus-doc-open-' + sessionId;
|
||||
const _docMinimizedKey = (sessionId) => 'odysseus-doc-minimized-' + sessionId;
|
||||
@@ -158,6 +159,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
getDocs: () => docs,
|
||||
isOpen: () => isOpen,
|
||||
createDocument,
|
||||
newDocument,
|
||||
loadDocument,
|
||||
switchToDoc,
|
||||
openPanel,
|
||||
@@ -2244,6 +2246,18 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
// ── Email document type helpers ──
|
||||
|
||||
function _unfoldEmailHeaderLines(header) {
|
||||
const lines = [];
|
||||
for (const rawLine of String(header || '').replace(/\r\n/g, '\n').split('\n')) {
|
||||
if (/^[ \t]/.test(rawLine) && lines.length) {
|
||||
lines[lines.length - 1] += ' ' + rawLine.trim();
|
||||
} else {
|
||||
lines.push(rawLine);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function _parseEmailHeader(content) {
|
||||
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: content || '' };
|
||||
if (!content) return empty;
|
||||
@@ -2252,7 +2266,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const header = parts[0];
|
||||
const body = parts.slice(1).join('\n---\n');
|
||||
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: body };
|
||||
for (const line of header.split('\n')) {
|
||||
for (const line of _unfoldEmailHeaderLines(header)) {
|
||||
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Forward-Attachments|X-Attachments):\s*(.*)$/i);
|
||||
if (m) {
|
||||
let key = m[1].toLowerCase();
|
||||
@@ -2373,16 +2387,20 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
function _emailFieldsWithLocalDraft(fields) {
|
||||
const draft = _loadEmailLocalDraft(fields);
|
||||
if (!draft) return fields;
|
||||
const keepRealField = (draftValue, fieldValue) => {
|
||||
const d = draftValue == null ? '' : String(draftValue);
|
||||
return d.trim() ? d : (fieldValue || '');
|
||||
};
|
||||
return {
|
||||
...fields,
|
||||
to: draft.to ?? fields.to,
|
||||
cc: draft.cc ?? fields.cc,
|
||||
bcc: draft.bcc ?? fields.bcc,
|
||||
subject: draft.subject ?? fields.subject,
|
||||
inReplyTo: draft.inReplyTo ?? fields.inReplyTo,
|
||||
references: draft.references ?? fields.references,
|
||||
sourceUid: draft.sourceUid ?? fields.sourceUid,
|
||||
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
|
||||
to: keepRealField(draft.to, fields.to),
|
||||
cc: keepRealField(draft.cc, fields.cc),
|
||||
bcc: keepRealField(draft.bcc, fields.bcc),
|
||||
subject: keepRealField(draft.subject, fields.subject),
|
||||
inReplyTo: keepRealField(draft.inReplyTo, fields.inReplyTo),
|
||||
references: keepRealField(draft.references, fields.references),
|
||||
sourceUid: keepRealField(draft.sourceUid, fields.sourceUid),
|
||||
sourceFolder: keepRealField(draft.sourceFolder, fields.sourceFolder),
|
||||
body: _sanitizeOutgoingEmailBody(draft.body ?? fields.body),
|
||||
};
|
||||
}
|
||||
@@ -2439,7 +2457,20 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
return d.innerHTML.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function _emailBodyToHtml(text) {
|
||||
function _emailHtmlToPlainText(html) {
|
||||
if (typeof document === 'undefined') return String(html || '');
|
||||
const d = document.createElement('div');
|
||||
d.innerHTML = String(html || '');
|
||||
return d.innerText || d.textContent || '';
|
||||
}
|
||||
|
||||
function _emailQuoteMarkerMatch(text) {
|
||||
const raw = String(text || '');
|
||||
return raw.match(/(?:<p[^>]*>\s*)?-{5,}\s*Previous message\s*-{5,}(?:\s*<\/p>)?/i)
|
||||
|| raw.match(/-{5,}\s*Previous message\s*-{5,}/i);
|
||||
}
|
||||
|
||||
function _emailBodyFragmentToHtml(text) {
|
||||
const t = (text || '').trim();
|
||||
if (!t) return '';
|
||||
// If it already contains a formatting/structural HTML tag, it's a saved
|
||||
@@ -2455,6 +2486,36 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
|
||||
catch (_) { return _emailPlainTextToHtml(text); }
|
||||
}
|
||||
|
||||
function _emailBodyToHtml(text) {
|
||||
const raw = String(text || '');
|
||||
const marker = _emailQuoteMarkerMatch(raw);
|
||||
if (!marker) {
|
||||
const t = raw.trim();
|
||||
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) {
|
||||
return markdownModule.sanitizeAllowedHtml
|
||||
? markdownModule.sanitizeAllowedHtml(t)
|
||||
: _emailPlainTextToHtml(t);
|
||||
}
|
||||
return _emailBodyFragmentToHtml(raw);
|
||||
}
|
||||
const replyPart = raw.slice(0, marker.index);
|
||||
const quotedPart = raw.slice(marker.index);
|
||||
const quotedText = _emailHtmlToPlainText(quotedPart)
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
const replyHtml = _emailBodyFragmentToHtml(replyPart);
|
||||
if (!quotedText) return replyHtml;
|
||||
const firstQuoted = quotedText
|
||||
.split(/\n\s*-{5,}\s*Previous message\s*-{5,}\s*\n/i)[0]
|
||||
.trim();
|
||||
const truncatedQuote = firstQuoted.length > 1800
|
||||
? `${firstQuoted.slice(0, 1800).replace(/\s+\S*$/, '').trim()}\n\n[Quoted thread truncated]`
|
||||
: firstQuoted;
|
||||
return `${replyHtml}<div class="email-quoted-history" contenteditable="false">${_emailPlainTextToHtml(truncatedQuote)}</div>`;
|
||||
}
|
||||
// Mirror the rich body's plain text into the hidden textarea so the existing
|
||||
// send / draft / change-detection plumbing (which reads the textarea) stays
|
||||
// valid. The rich body's HTML is read separately on send (body_html).
|
||||
@@ -2756,8 +2817,21 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
target.focus();
|
||||
if (target.isContentEditable) {
|
||||
const range = document.createRange();
|
||||
const quote = target.querySelector('.email-quoted-history');
|
||||
if (quote) {
|
||||
let slot = quote.previousElementSibling;
|
||||
if (!slot || slot.classList.contains('email-quoted-history')) {
|
||||
slot = document.createElement('div');
|
||||
slot.className = 'email-reply-edit-slot';
|
||||
slot.innerHTML = '<br>';
|
||||
target.insertBefore(slot, quote);
|
||||
}
|
||||
range.selectNodeContents(slot);
|
||||
range.collapse(false);
|
||||
} else {
|
||||
range.selectNodeContents(target);
|
||||
range.collapse(false);
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
@@ -2820,7 +2894,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
|
||||
}
|
||||
|
||||
function _showEmailFields(doc, { applyLocalDraft = true } = {}) {
|
||||
function _showEmailFields(doc, { applyLocalDraft = true, forceHeaderFields = false } = {}) {
|
||||
const emailHeader = document.getElementById('doc-email-header');
|
||||
const emailActions = document.getElementById('doc-email-actions');
|
||||
// Show MD toolbar for email too (B, I, etc.)
|
||||
@@ -2857,8 +2931,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const preserveEmailHeader = !!(fields.sourceUid || fields.inReplyTo || fields.references);
|
||||
const subjectInput = document.getElementById('doc-email-subject');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
|
||||
if (subjectInput && !subjectInput._emailTabBodyBound) {
|
||||
subjectInput._emailTabBodyBound = true;
|
||||
@@ -2869,10 +2943,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
});
|
||||
}
|
||||
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
// Show/hide unread button only if we have a source UID (came from inbox)
|
||||
const unreadBtn = document.getElementById('doc-email-unread-btn');
|
||||
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
|
||||
@@ -2984,7 +3058,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const _isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0;
|
||||
if (!_isTouch) _rich.focus();
|
||||
if (!_isTouch) _focusEmailBodyEnd();
|
||||
_rich.scrollTop = 0;
|
||||
} catch (_) {}
|
||||
}, 50);
|
||||
@@ -2995,8 +3069,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const ccRow = document.getElementById('doc-email-cc-row');
|
||||
const bccRow = document.getElementById('doc-email-bcc-row');
|
||||
const ccToggle = document.getElementById('doc-email-show-cc');
|
||||
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
const hasCcBcc = !!(
|
||||
fields.cc ||
|
||||
fields.bcc ||
|
||||
@@ -3777,6 +3851,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
|
||||
async function _sendEmail() {
|
||||
if (_emailSendInFlight) {
|
||||
if (uiModule) uiModule.showToast('Already sending');
|
||||
return;
|
||||
}
|
||||
if (uiModule) uiModule.showToast('Preparing send', { duration: 1200 });
|
||||
const sendDocId = activeDocId;
|
||||
const to = document.getElementById('doc-email-to')?.value?.trim();
|
||||
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
|
||||
@@ -3810,11 +3889,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const proceed = await _confirmMissingAttachment();
|
||||
if (!proceed) return;
|
||||
}
|
||||
const btn = document.getElementById('doc-email-send-btn');
|
||||
const _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const btn = Array.from(document.querySelectorAll('#doc-email-send-btn')).find((candidate) => candidate.offsetParent !== null) || document.getElementById('doc-email-send-btn');
|
||||
let sendSpinner = null;
|
||||
let origBtnHtml = '';
|
||||
let detachedEmailDoc = null;
|
||||
_emailSendInFlight = true;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
origBtnHtml = btn.innerHTML;
|
||||
@@ -3825,24 +3903,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
btn.appendChild(document.createTextNode('Sending'));
|
||||
}
|
||||
try {
|
||||
let canceled = false;
|
||||
if (uiModule) {
|
||||
uiModule.showToast('Sending', {
|
||||
duration: 3200,
|
||||
leadingIcon: 'spinner',
|
||||
action: 'Cancel',
|
||||
onAction: () => { canceled = true; },
|
||||
});
|
||||
}
|
||||
await _sleep(3000);
|
||||
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
|
||||
await _sleep(200);
|
||||
if (canceled) {
|
||||
_restoreDetachedEmailDoc(detachedEmailDoc);
|
||||
detachedEmailDoc = null;
|
||||
if (uiModule) uiModule.showToast('Send canceled');
|
||||
return;
|
||||
}
|
||||
if (uiModule) uiModule.showToast('Sending', { duration: 2200, leadingIcon: 'spinner' });
|
||||
|
||||
const activeAccountId = await _resolveComposeSendAccountId();
|
||||
const res = await fetch(`${API_BASE}/api/email/send`, {
|
||||
@@ -3873,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
leadingIcon: 'check',
|
||||
action: 'View Message',
|
||||
onAction: () => {
|
||||
import('./emailLibrary.js').then(mod => {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (open) open({
|
||||
account_id: data.account_id || activeAccountId || null,
|
||||
@@ -3912,9 +3973,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// Tell the inbox to refresh so the answered state shows
|
||||
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid, folder: sourceFolder, account_id: data.account_id || activeAccountId || null } }));
|
||||
}
|
||||
// Delete the compose document after successful send. It was usually
|
||||
// already detached from the visible tabs so sending can finish in the
|
||||
// background while the user continues in the next tab.
|
||||
// Delete the compose document after successful send.
|
||||
if (sendDocId) {
|
||||
fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' }).catch(() => {});
|
||||
const wasActiveSentDoc = activeDocId === sendDocId;
|
||||
@@ -3930,15 +3989,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
_syncDocIndicator();
|
||||
}
|
||||
} else {
|
||||
_restoreDetachedEmailDoc(detachedEmailDoc);
|
||||
detachedEmailDoc = null;
|
||||
if (uiModule) uiModule.showError(data.error || 'Failed to send');
|
||||
}
|
||||
} catch (e) {
|
||||
_restoreDetachedEmailDoc(detachedEmailDoc);
|
||||
detachedEmailDoc = null;
|
||||
if (uiModule) uiModule.showError(e?.message ? `Failed to send email: ${e.message}` : 'Failed to send email');
|
||||
} finally {
|
||||
_emailSendInFlight = false;
|
||||
if (sendSpinner) sendSpinner.destroy();
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
@@ -4013,41 +4069,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
return ids;
|
||||
}
|
||||
|
||||
function _detachActiveEmailForBackground(docId) {
|
||||
if (!docId || !docs.has(docId)) return null;
|
||||
saveCurrentToMap();
|
||||
const doc = docs.get(docId);
|
||||
const snapshot = { id: docId, doc: { ...doc } };
|
||||
const wasActive = activeDocId === docId;
|
||||
if (wasActive) saveDocument({ silent: true }).catch(() => {});
|
||||
|
||||
const visibleBefore = _visibleDocIdsForCurrentSession();
|
||||
const idx = visibleBefore.indexOf(docId);
|
||||
docs.delete(docId);
|
||||
if (wasActive) activeDocId = null;
|
||||
|
||||
if (wasActive) {
|
||||
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
|
||||
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
|
||||
if (nextId) {
|
||||
switchToDoc(nextId);
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
renderTabs();
|
||||
_syncDocIndicator();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function _restoreDetachedEmailDoc(snapshot) {
|
||||
if (!snapshot || !snapshot.id || !snapshot.doc) return;
|
||||
if (!docs.has(snapshot.id)) docs.set(snapshot.id, snapshot.doc);
|
||||
_ensureDocPaneMounted();
|
||||
switchToDoc(snapshot.id);
|
||||
_syncDocIndicator();
|
||||
}
|
||||
|
||||
function _closeWithoutDeleting(deleteDoc = false) {
|
||||
if (!activeDocId) return;
|
||||
if (deleteDoc) {
|
||||
@@ -4068,10 +4089,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
// Fast/Full + optional context popover for the doc-editor email Reply button.
|
||||
// Mirrors the email reader's AI reply choice popover so the UX is identical:
|
||||
// textarea for an optional steering note, then Fast (lightning) or Full
|
||||
// (concentric dot) buttons; both feed into _aiReply with the chosen mode.
|
||||
// Fast AI reply + optional context popover for the doc-editor email Reply button.
|
||||
// Mirrors the email reader's AI reply choice popover: textarea for an
|
||||
// optional steering note, then one Submit button.
|
||||
let _docAiReplyChoiceMenu = null;
|
||||
const _AI_REPLY_CONTEXT_STORE_PREFIX = 'odysseus:email-ai-reply-context:v1:';
|
||||
function _docAiReplyContextKey() {
|
||||
@@ -4149,15 +4169,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
].join(';');
|
||||
menu.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
|
||||
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<textarea data-note-input rows="2" placeholder="Context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Fast
|
||||
</button>
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
|
||||
Full
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Draft reply" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4203,6 +4219,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || '';
|
||||
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || '';
|
||||
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
|
||||
const sourceAccountId = docs.get(activeDocId)?.sourceEmailAccountId || window.__odysseusActiveEmailAccount || '';
|
||||
const cleanAiReplyText = (text) => {
|
||||
if (!text) return '';
|
||||
let t = String(text);
|
||||
@@ -4217,15 +4234,16 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
return t
|
||||
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
|
||||
.replace(/<<<\s*END\s*>>+/gi, '')
|
||||
.replace(/<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi, '')
|
||||
.trim();
|
||||
};
|
||||
const shouldUseFastAiReply = () => {
|
||||
const text = `${subject}\n${currentBody}`.toLowerCase();
|
||||
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
|
||||
return false;
|
||||
const splitCurrent = _splitEmailReplyQuote(currentBody);
|
||||
const ownText = String(splitCurrent.body || '').trim();
|
||||
const isReplaceableDraft = !ownText || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText);
|
||||
if (!isReplaceableDraft) {
|
||||
if (uiModule) uiModule.showToast('Reply already has text');
|
||||
return;
|
||||
}
|
||||
return currentBody.length < 2500;
|
||||
};
|
||||
|
||||
// Use the current chat model
|
||||
let currentModel = '';
|
||||
@@ -4243,9 +4261,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// so the backend's "no body" guard doesn't fail. The user_hint carries
|
||||
// the user's compose intent; the model uses To/Subject + that hint.
|
||||
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
|
||||
const fastFlag = mode === 'ai-reply-fast' ? true
|
||||
: mode === 'ai-reply-full' ? false
|
||||
: shouldUseFastAiReply();
|
||||
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -4258,7 +4273,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
message_id: inReplyTo,
|
||||
uid: sourceUid,
|
||||
folder: sourceFolder,
|
||||
fast: fastFlag,
|
||||
account_id: sourceAccountId,
|
||||
fast: true,
|
||||
user_hint: noteHint || '',
|
||||
}),
|
||||
});
|
||||
@@ -4271,11 +4287,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// currentBody. Without this, AI's invented quote stacked on top
|
||||
// of the real one and looked like the history had been "edited".
|
||||
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
|
||||
// Never overwrite the existing draft (user's typed text + the
|
||||
// quoted history below it). Always prepend the AI suggestion so
|
||||
// the user can read it, copy parts, or delete it — but their
|
||||
// own work and the original quote are untouched.
|
||||
const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply;
|
||||
const quote = splitCurrent.quote || '';
|
||||
const newBody = cleanReply + (quote ? `\n\n${quote}` : '');
|
||||
await _streamEmailBodyText(textarea, newBody);
|
||||
_clearDocAiReplyContext(contextKey || _docAiReplyContextKey());
|
||||
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
|
||||
@@ -4568,7 +4581,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const isEmail = doc.language === 'email';
|
||||
if (isEmail) {
|
||||
_setMarkdownPreviewActive(false, { remember: false });
|
||||
_showEmailFields(doc);
|
||||
const forceHeaderFields = !!doc._skipLocalDraftOnce;
|
||||
const applyLocalDraft = forceHeaderFields ? false : true;
|
||||
doc._skipLocalDraftOnce = false;
|
||||
_showEmailFields(doc, { applyLocalDraft, forceHeaderFields });
|
||||
} else {
|
||||
_hideEmailFields();
|
||||
const wantsMarkdownPreview = (doc.language || 'markdown') === 'markdown' && doc._markdownPreviewActive === true;
|
||||
@@ -4913,7 +4929,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
</span>
|
||||
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
|
||||
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (fast + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
|
||||
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
|
||||
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
|
||||
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
|
||||
@@ -4963,8 +4979,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
<button id="doc-email-discard-btn" class="email-discard-btn" title="Close email" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Close</span></button>
|
||||
<span style="flex:1"></span>
|
||||
<div class="email-send-split">
|
||||
<button id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
|
||||
<button id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button type="button" id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)" onpointerdown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onmousedown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onclick="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
|
||||
<button type="button" id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false" onpointerdown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)" onmousedown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<div id="doc-email-more-menu" class="email-more-menu" style="display:none">
|
||||
<div class="dropdown-item-compact" id="doc-email-draft-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>Save Draft</div>
|
||||
<div class="dropdown-item-compact" id="doc-email-schedule-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>Schedule Send...</div>
|
||||
@@ -5400,13 +5416,80 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
));
|
||||
}
|
||||
|
||||
document.getElementById('doc-email-send-btn')?.addEventListener('click', () => {
|
||||
// Pressing Send must never leave the "more options" menu showing.
|
||||
const _eventInsideElement = (e, el) => {
|
||||
if (!e || !el || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
};
|
||||
|
||||
const handleSendIntent = (e) => {
|
||||
if (e && e.__odysseusEmailSendHandled) return;
|
||||
const rawTarget = e && e.target;
|
||||
const target = rawTarget && rawTarget.nodeType === Node.TEXT_NODE ? rawTarget.parentElement : rawTarget;
|
||||
const sendButtons = Array.from(document.querySelectorAll('#doc-email-send-btn'));
|
||||
const targetBtn = target && target.closest ? target.closest('#doc-email-send-btn') : null;
|
||||
const rectBtn = sendButtons.find((candidate) => _eventInsideElement(e, candidate));
|
||||
const btn = targetBtn || rectBtn || null;
|
||||
if (!btn || btn.disabled) return;
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.__odysseusEmailSendHandled = true;
|
||||
}
|
||||
const _m = document.getElementById('doc-email-more-menu');
|
||||
if (_m) _m.style.display = 'none';
|
||||
document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false');
|
||||
_sendEmail();
|
||||
};
|
||||
window.odysseusEmailSendIntent = handleSendIntent;
|
||||
if (!window._emailSendDelegatedBoundV3) {
|
||||
window._emailSendDelegatedBoundV3 = true;
|
||||
['pointerdown', 'mousedown', 'pointerup', 'click'].forEach((type) => {
|
||||
window.addEventListener(type, handleSendIntent, true);
|
||||
document.addEventListener(type, handleSendIntent, true);
|
||||
});
|
||||
}
|
||||
|
||||
let lastCaretToggleAt = 0;
|
||||
const toggleSendMenu = (caret) => {
|
||||
const menu = document.getElementById('doc-email-more-menu');
|
||||
if (!menu) return;
|
||||
const opening = menu.style.display === 'none';
|
||||
menu.style.display = opening ? '' : 'none';
|
||||
if (caret) caret.setAttribute('aria-expanded', String(opening));
|
||||
};
|
||||
const handleCaretIntent = (e) => {
|
||||
if (e && e.__odysseusEmailCaretHandled) return;
|
||||
const now = Date.now();
|
||||
if (e && e.type === 'click' && now - lastCaretToggleAt < 350) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.__odysseusEmailCaretHandled = true;
|
||||
return;
|
||||
}
|
||||
const rawTarget = e && e.target;
|
||||
const target = rawTarget && rawTarget.nodeType === Node.TEXT_NODE ? rawTarget.parentElement : rawTarget;
|
||||
const carets = Array.from(document.querySelectorAll('#doc-email-send-caret'));
|
||||
const targetCaret = target && target.closest ? target.closest('#doc-email-send-caret') : null;
|
||||
const rectCaret = carets.find((candidate) => _eventInsideElement(e, candidate));
|
||||
const caret = targetCaret || rectCaret || null;
|
||||
if (!caret) return;
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.__odysseusEmailCaretHandled = true;
|
||||
}
|
||||
lastCaretToggleAt = now;
|
||||
toggleSendMenu(caret);
|
||||
};
|
||||
window.odysseusEmailCaretIntent = handleCaretIntent;
|
||||
if (!window._emailCaretDelegatedBoundV1) {
|
||||
window._emailCaretDelegatedBoundV1 = true;
|
||||
['pointerdown', 'mousedown', 'click'].forEach((type) => {
|
||||
window.addEventListener(type, handleCaretIntent, true);
|
||||
document.addEventListener(type, handleCaretIntent, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Ctrl+Enter / Cmd+Enter sends the email when an email doc is active
|
||||
// Bind once at module level via a guard to avoid duplicate listeners on re-open
|
||||
@@ -5493,16 +5576,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader);
|
||||
}
|
||||
|
||||
// Split-button caret toggles the send-options menu (drops up).
|
||||
document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const menu = document.getElementById('doc-email-more-menu');
|
||||
const caret = document.getElementById('doc-email-send-caret');
|
||||
if (!menu) return;
|
||||
const opening = menu.style.display === 'none';
|
||||
menu.style.display = opening ? '' : 'none';
|
||||
if (caret) caret.setAttribute('aria-expanded', String(opening));
|
||||
});
|
||||
// Split-button caret toggles the send-options menu.
|
||||
document.getElementById('doc-email-send-caret')?.addEventListener('click', handleCaretIntent);
|
||||
document.addEventListener('click', (e) => {
|
||||
const menu = document.getElementById('doc-email-more-menu');
|
||||
// Keep the menu open ONLY while interacting with the caret itself or the
|
||||
@@ -7009,6 +7084,13 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
export function injectFreshDoc(doc) {
|
||||
if (!doc || !doc.id) return;
|
||||
const sessionId = doc.session_id || _lastSessionId || null;
|
||||
if (doc.language === 'email') {
|
||||
doc._skipLocalDraftOnce = true;
|
||||
try {
|
||||
const fields = _parseEmailHeader(doc.current_content || doc.content || '');
|
||||
_clearEmailLocalDraft(fields.sourceUid, fields.sourceFolder, fields.inReplyTo);
|
||||
} catch (_) {}
|
||||
}
|
||||
addDocToTabs(doc, sessionId);
|
||||
// Use _ensureDocPaneMounted (not `if (!isOpen) openPanel()`): when a draft
|
||||
// is composed from the email modal, `isOpen` can be stale-true while the
|
||||
@@ -7016,10 +7098,13 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// mounts into a wrong/half-built pane (rendered as a narrow sidebar on
|
||||
// mobile instead of its own full-screen window). This remounts it cleanly.
|
||||
_ensureDocPaneMounted();
|
||||
// Defer to next frame so the panel DOM exists before switchToDoc populates
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
switchToDoc(doc.id);
|
||||
}));
|
||||
// Defer to the next frame so the panel DOM exists before switchToDoc
|
||||
// populates it. Do not call switchToDoc synchronously here: it saves the
|
||||
// previously active doc and can re-enter the email draft path while a reply
|
||||
// document is still being injected.
|
||||
requestAnimationFrame(() => {
|
||||
if (docs.has(doc.id)) switchToDoc(doc.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceEmailReplyBody(docId, replyText, { force = false } = {}) {
|
||||
@@ -7053,6 +7138,90 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
}
|
||||
|
||||
function _buildEmailContentFromFields(fields, body) {
|
||||
const f = fields || {};
|
||||
let header = `To: ${f.to || ''}`;
|
||||
if (f.cc) header += `\nCc: ${f.cc}`;
|
||||
if (f.bcc) header += `\nBcc: ${f.bcc}`;
|
||||
header += `\nSubject: ${f.subject || ''}`;
|
||||
if (f.inReplyTo) header += `\nIn-Reply-To: ${f.inReplyTo}`;
|
||||
if (f.references) header += `\nReferences: ${f.references}`;
|
||||
if (f.sourceUid) header += `\nX-Source-UID: ${f.sourceUid}`;
|
||||
if (f.sourceFolder) header += `\nX-Source-Folder: ${f.sourceFolder}`;
|
||||
if (f.forwardAttachments) header += `\nX-Forward-Attachments: 1`;
|
||||
if (Array.isArray(f.attachments) && f.attachments.length) {
|
||||
const attStr = f.attachments
|
||||
.map(a => `${a.index}:${a.filename}:${a.size}`)
|
||||
.join('|');
|
||||
header += `\nX-Attachments: ${attStr}`;
|
||||
}
|
||||
return header + '\n---\n' + (body || '');
|
||||
}
|
||||
|
||||
export async function ensureEmailDraftEnvelope(docId, freshContent) {
|
||||
const doc = docs.get(docId);
|
||||
if (!doc || doc.language !== 'email') return false;
|
||||
const current = _parseEmailHeader(doc.content || '');
|
||||
const fresh = _parseEmailHeader(freshContent || '');
|
||||
if (!fresh.to && !fresh.subject && !fresh.sourceUid) return false;
|
||||
|
||||
const needsEnvelope = (
|
||||
(!current.to && !!fresh.to) ||
|
||||
(!current.subject && !!fresh.subject) ||
|
||||
(!current.inReplyTo && !!fresh.inReplyTo) ||
|
||||
(!current.references && !!fresh.references) ||
|
||||
(!current.sourceUid && !!fresh.sourceUid) ||
|
||||
(!current.sourceFolder && !!fresh.sourceFolder)
|
||||
);
|
||||
|
||||
const currentSplit = _splitEmailReplyQuote(current.body || '');
|
||||
const freshSplit = _splitEmailReplyQuote(fresh.body || '');
|
||||
const currentOwnText = String(currentSplit.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const freshOwnText = String(freshSplit.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const currentBodyText = String(current.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const freshBodyText = String(fresh.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const needsBodyRepair = (
|
||||
!!freshBodyText &&
|
||||
(
|
||||
!currentBodyText ||
|
||||
(!currentOwnText && !!freshOwnText) ||
|
||||
(!currentSplit.quote && !!freshSplit.quote)
|
||||
)
|
||||
);
|
||||
if (!needsEnvelope && !needsBodyRepair) return false;
|
||||
|
||||
let body = current.body || '';
|
||||
if (needsBodyRepair && (!currentBodyText || (!currentOwnText && !!freshOwnText))) {
|
||||
body = fresh.body || '';
|
||||
} else if (!String(body).trim()) {
|
||||
body = fresh.body || '';
|
||||
} else if (currentSplit.body && freshSplit.quote && !currentSplit.quote) {
|
||||
body = `${currentSplit.body}\n\n${freshSplit.quote}`;
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...fresh,
|
||||
to: current.to || fresh.to || '',
|
||||
cc: current.cc || fresh.cc || '',
|
||||
bcc: current.bcc || fresh.bcc || '',
|
||||
subject: current.subject || fresh.subject || '',
|
||||
inReplyTo: current.inReplyTo || fresh.inReplyTo || '',
|
||||
references: current.references || fresh.references || '',
|
||||
sourceUid: current.sourceUid || fresh.sourceUid || '',
|
||||
sourceFolder: current.sourceFolder || fresh.sourceFolder || '',
|
||||
forwardAttachments: current.forwardAttachments || fresh.forwardAttachments || false,
|
||||
attachments: (current.attachments && current.attachments.length) ? current.attachments : (fresh.attachments || []),
|
||||
};
|
||||
|
||||
doc.content = _buildEmailContentFromFields(merged, body);
|
||||
if (activeDocId === docId) {
|
||||
_showEmailFields(doc, { applyLocalDraft: false });
|
||||
}
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Force the panel into a genuinely-open state. `isOpen` can be true while the
|
||||
// pane was torn down by another full-screen view (e.g. opening a doc from the
|
||||
// email modal): in that case openPanel() early-returns and nothing mounts, so
|
||||
@@ -7187,31 +7356,22 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
_syncDocIndicator();
|
||||
// Switch to the most recently active one (or first)
|
||||
const target = activeDocs[0];
|
||||
if (restoreMode && shouldRestoreMinimized && !shouldRestoreOpen) {
|
||||
activeDocId = null;
|
||||
if (restoreMode && !shouldRestoreOpen) {
|
||||
// Coming back to a chat with documents should advertise the doc without
|
||||
// stealing half the screen. Default to a docked chip; only reopen the
|
||||
// full editor when this session explicitly persisted an open state.
|
||||
activeDocId = target.id;
|
||||
_minimizedDocId = target.id;
|
||||
_markDocVisibleState(sessionId, 'minimized');
|
||||
_ensureDocChipRegistered();
|
||||
if (isOpen) {
|
||||
try { switchToDoc(target.id); } catch (e) { console.error('Minimize restored doc failed:', e); }
|
||||
closePanel('down');
|
||||
} else {
|
||||
Modals.minimize('doc-panel');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Removed: the old "if restoreMode && !shouldRestoreOpen → stay
|
||||
// closed" branch. Users expect that entering a chat with an
|
||||
// attached document opens the panel automatically, not just shows
|
||||
// an indicator. The minimised branch above still respects an
|
||||
// explicit user choice to dock the panel; everything else falls
|
||||
// through to the "open panel" path below.
|
||||
if (false) {
|
||||
activeDocId = null;
|
||||
_minimizedDocId = null;
|
||||
if (Modals.isRegistered('doc-panel')) Modals.unregister('doc-panel');
|
||||
return;
|
||||
}
|
||||
// Always open when there are docs — the minimised branch above
|
||||
// already returned for users who explicitly docked the panel.
|
||||
// The previous `if (!restoreMode || shouldRestoreOpen)` gate left
|
||||
// the panel closed on first entry to a chat with docs, which
|
||||
// hides the doc unless the user manually opens the panel.
|
||||
_markDocVisibleState(sessionId, 'open');
|
||||
if (!isOpen) openPanel();
|
||||
switchToDoc(target.id);
|
||||
@@ -7231,11 +7391,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
id: doc.id,
|
||||
title: doc.title || '',
|
||||
language: doc.language || '',
|
||||
content: doc.current_content || '',
|
||||
content: doc.current_content || doc.content || '',
|
||||
version: doc.version_count || 1,
|
||||
sessionId: sessionId || doc.session_id,
|
||||
userSetLanguage: !!doc.language,
|
||||
_composeAtts: existing?._composeAtts,
|
||||
_skipLocalDraftOnce: !!doc._skipLocalDraftOnce,
|
||||
// Provenance for the "Send signed reply" flow
|
||||
sourceEmailUid: doc.source_email_uid || null,
|
||||
sourceEmailFolder: doc.source_email_folder || null,
|
||||
@@ -11010,6 +11171,7 @@ const documentModule = {
|
||||
loadDocument,
|
||||
injectFreshDoc,
|
||||
replaceEmailReplyBody,
|
||||
ensureEmailDraftEnvelope,
|
||||
ensurePaneMounted: _ensureDocPaneMounted,
|
||||
loadSessionDocs,
|
||||
ensureDocPanel,
|
||||
|
||||
@@ -19,6 +19,7 @@ let _esc; // HTML-escape function
|
||||
let _getDocs; // () => Map of open docs
|
||||
let _isOpenFn; // () => boolean — is doc panel open
|
||||
let _createDocument;
|
||||
let _newDocument;
|
||||
let _loadDocument;
|
||||
let _switchToDoc;
|
||||
let _openPanel;
|
||||
@@ -31,6 +32,7 @@ export function initLibrary(config) {
|
||||
_getDocs = config.getDocs;
|
||||
_isOpenFn = config.isOpen;
|
||||
_createDocument = config.createDocument;
|
||||
_newDocument = config.newDocument;
|
||||
_loadDocument = config.loadDocument;
|
||||
_switchToDoc = config.switchToDoc;
|
||||
_openPanel = config.openPanel;
|
||||
@@ -3224,16 +3226,16 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const createBtn = document.getElementById('doclib-create-btn');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', async () => {
|
||||
// Create a new session, then create a blank document in it
|
||||
try {
|
||||
const sRes = await fetch('/api/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled Document' }) });
|
||||
const sData = await sRes.json();
|
||||
const sessionId = sData.session_id;
|
||||
if (_newDocument) {
|
||||
await _newDocument();
|
||||
} else {
|
||||
const sessionId = sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) throw new Error('No active session');
|
||||
await _createDocument(sessionId);
|
||||
// Close library and open the new session
|
||||
}
|
||||
closeLibrary();
|
||||
if (window.sessionsModule) window.sessionsModule.loadSession(sessionId);
|
||||
setTimeout(() => _openPanel(), 300);
|
||||
setTimeout(() => _openPanel(), 50);
|
||||
} catch (e) {
|
||||
console.error('Failed to create document:', e);
|
||||
if (uiModule) uiModule.showError('Failed to create document');
|
||||
|
||||
@@ -136,6 +136,10 @@ export function wireInpaintButtons({
|
||||
const dilatedMask = dilateMask(mergedMask, padPx);
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
|
||||
const baseSnap = document.createElement('canvas');
|
||||
baseSnap.width = state.imgWidth;
|
||||
baseSnap.height = state.imgHeight;
|
||||
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
|
||||
const res = await fetch('/api/image/inpaint', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -180,7 +184,7 @@ export function wireInpaintButtons({
|
||||
maskSnap.width = state.maskCanvas.width;
|
||||
maskSnap.height = state.maskCanvas.height;
|
||||
maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0);
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, padPx };
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
state.layers.push(resultLayer);
|
||||
@@ -216,7 +220,9 @@ export function wireInpaintButtons({
|
||||
const eRow = document.getElementById('ge-inpaint-edgestroke-row');
|
||||
const eSlider = document.getElementById('ge-edgestroke-slider');
|
||||
const eLabel = document.getElementById('ge-edgestroke-label');
|
||||
const autoRow = document.getElementById('ge-inpaint-automatch-row');
|
||||
if (eRow) eRow.style.display = '';
|
||||
if (autoRow) autoRow.style.display = '';
|
||||
if (eSlider) {
|
||||
eSlider.max = String(padPx);
|
||||
eSlider.min = String(-padPx);
|
||||
|
||||
Vendored
+35
@@ -102,6 +102,35 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
|
||||
</div>
|
||||
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
|
||||
</div>
|
||||
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
|
||||
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
|
||||
<button class="ge-btn ge-btn-sm ge-btn-ai" id="ge-sam-find" style="height:28px;display:inline-flex;align-items:center;gap:5px;" title="Find object and create a SAM mask">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the selection">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
Clear
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
|
||||
</div>
|
||||
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
|
||||
<div class="ge-inpaint-popover-head" data-inpaint-drag>
|
||||
<div class="ge-section-title ge-section-title-with-help ge-inpaint-popover-title"><span>INPAINT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How inpaint works" title="Brush the area you want the AI to redraw — the red preview marks the mask region. Use Paint to add, Erase to subtract (or hold Ctrl+Alt to flip for one stroke). Generate fills with what your prompt describes; Remove fills with the surrounding background.">?</span></div>
|
||||
@@ -189,6 +218,12 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<label>Edge stroke <span id="ge-edgestroke-label">0px</span></label>
|
||||
<input type="range" id="ge-edgestroke-slider" min="-80" max="80" value="0" title="Expand (+) or contract (−) the inpaint layer's edge before feathering. Uses the AI buffer generated around your brush." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" id="ge-inpaint-automatch-row" style="display:none;margin-top:6px;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel ge-btn-ai" id="ge-inpaint-automatch" style="width:100%;justify-content:center;" title="Match the latest inpaint result to the surrounding colour and lighting using an adjustment layer.">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
Auto match color
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-clone-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>Clone</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How clone works" title="Alt-click (desktop) or double-tap (mobile) somewhere on the canvas to set the sample source. Then drag elsewhere to clone those pixels onto the active layer. The source point moves with your brush so the offset stays constant. Size / Opacity / Flow / Softness come from the Brush panel.">?</span></div>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>', key: 'K' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
|
||||
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
|
||||
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
|
||||
@@ -54,8 +55,9 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
// Selection-clear badge — rendered only for tools that can hold a
|
||||
// selection (lasso, wand). Inpaint masks are first-class sub-layers
|
||||
// now so they get their own delete-X in the layer panel.
|
||||
const clearBadge = (t.id === 'lasso' || t.id === 'wand')
|
||||
? '<span class="ge-tool-clear" title="Clear selection" data-clear-tool="' + t.id + '">' +
|
||||
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
const clearBadge = (t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
|
||||
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
|
||||
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
|
||||
'</span>'
|
||||
: '';
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* openFxPopup: (layer: object, anchor: HTMLElement) => void,
|
||||
* editAdjLayer: (layer: object, adj: object, anchor: HTMLElement) => void,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* renderLayer?: (layer: object) => HTMLCanvasElement,
|
||||
* lassoToMask: () => void,
|
||||
* wandToMask: () => void,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
@@ -54,7 +55,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
const {
|
||||
composite, saveState, showLayerThumb, hideLayerThumb,
|
||||
loadLayerAlphaAsSelection, openFxPopup, editAdjLayer,
|
||||
createLayer, lassoToMask, wandToMask, getActiveMaskLayer,
|
||||
createLayer, renderLayer, lassoToMask, wandToMask, getActiveMaskLayer,
|
||||
syncFxPanelToActiveLayerIfPresent,
|
||||
dragSortModule, uiModule,
|
||||
} = deps;
|
||||
@@ -336,7 +337,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
mergeDownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
saveState(`Merge "${layer.name}" down`);
|
||||
mergeLayerDownAtIndex(i);
|
||||
mergeLayerDownAtIndex(i, renderLayer);
|
||||
composite();
|
||||
render();
|
||||
uiModule.showToast('Layer merged down');
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
|
||||
* autoMatchInpaint: () => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* attachColorPicker: (el: HTMLInputElement) => void,
|
||||
* uiModule: object,
|
||||
@@ -37,7 +38,7 @@ const EYE_OPEN_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
const EYE_OFF_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
|
||||
export function wireInpaintControls({
|
||||
composite, applyInpaintFeather, syncToolClearIndicators,
|
||||
composite, applyInpaintFeather, autoMatchInpaint, syncToolClearIndicators,
|
||||
attachColorPicker, uiModule,
|
||||
}) {
|
||||
// ── Feather + Strength preview swatches ──
|
||||
@@ -93,6 +94,9 @@ export function wireInpaintControls({
|
||||
document.getElementById('ge-strength-label').textContent = (e.target.value / 100).toFixed(2);
|
||||
syncStrengthPreview(parseInt(e.target.value, 10));
|
||||
});
|
||||
document.getElementById('ge-inpaint-automatch')?.addEventListener('click', () => {
|
||||
if (typeof autoMatchInpaint === 'function') autoMatchInpaint();
|
||||
});
|
||||
syncFeatherPreview(0);
|
||||
syncStrengthPreview(75);
|
||||
|
||||
|
||||
@@ -15,32 +15,60 @@
|
||||
* createLayer: (name, w, h) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* composite: () => void,
|
||||
* renderLayer?: (layer) => HTMLCanvasElement,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function mergeLayerDownAtIndex(idx) {
|
||||
function _renderSource(layer, renderLayer) {
|
||||
if (!layer) return null;
|
||||
try {
|
||||
return typeof renderLayer === 'function' ? (renderLayer(layer) || layer.canvas) : layer.canvas;
|
||||
} catch {
|
||||
return layer.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
function _clearBakedAdjustments(layer) {
|
||||
if (!layer) return;
|
||||
layer.adjLayers = [];
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = '';
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = '';
|
||||
}
|
||||
|
||||
export function mergeLayerDownAtIndex(idx, renderLayer = null) {
|
||||
if (idx < 1 || idx >= state.layers.length) return null;
|
||||
const upper = state.layers[idx];
|
||||
const lower = state.layers[idx - 1];
|
||||
const upperOff = state.layerOffsets.get(upper.id) || { x: 0, y: 0 };
|
||||
const lowerOff = state.layerOffsets.get(lower.id) || { x: 0, y: 0 };
|
||||
lower.ctx.save();
|
||||
lower.ctx.globalAlpha = upper.opacity;
|
||||
lower.ctx.drawImage(
|
||||
upper.canvas,
|
||||
upperOff.x - lowerOff.x,
|
||||
upperOff.y - lowerOff.y,
|
||||
);
|
||||
lower.ctx.restore();
|
||||
const lowerSource = _renderSource(lower, renderLayer);
|
||||
const upperSource = _renderSource(upper, renderLayer);
|
||||
const merged = document.createElement('canvas');
|
||||
merged.width = state.imgWidth;
|
||||
merged.height = state.imgHeight;
|
||||
const mctx = merged.getContext('2d');
|
||||
mctx.globalAlpha = lower.opacity;
|
||||
mctx.drawImage(lowerSource, lowerOff.x, lowerOff.y);
|
||||
mctx.globalAlpha = upper.opacity;
|
||||
mctx.drawImage(upperSource, upperOff.x, upperOff.y);
|
||||
mctx.globalAlpha = 1;
|
||||
lower.canvas = merged;
|
||||
lower.ctx = lower.canvas.getContext('2d');
|
||||
lower.opacity = 1;
|
||||
lower.visible = true;
|
||||
state.layerOffsets.set(lower.id, { x: 0, y: 0 });
|
||||
_clearBakedAdjustments(lower);
|
||||
state.layers.splice(idx, 1);
|
||||
state.layerOffsets.delete(upper.id);
|
||||
state.activeLayerId = lower.id;
|
||||
return lower;
|
||||
}
|
||||
|
||||
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, uiModule }) {
|
||||
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, renderLayer, uiModule }) {
|
||||
// Flatten Copy.
|
||||
document.getElementById('ge-flatten')?.addEventListener('click', () => {
|
||||
if (state.layers.length < 2) return;
|
||||
@@ -51,9 +79,10 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
if (!l.visible) continue;
|
||||
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
|
||||
ctx.globalAlpha = l.opacity;
|
||||
ctx.drawImage(l.canvas, off.x, off.y);
|
||||
ctx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
_clearBakedAdjustments(merged);
|
||||
state.layers.push(merged);
|
||||
state.activeLayerId = merged.id;
|
||||
renderLayerPanel();
|
||||
@@ -70,14 +99,23 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
}
|
||||
saveState('Merge all');
|
||||
const base = visibleLayers[0];
|
||||
const baseCtx = base.ctx;
|
||||
for (let i = 1; i < visibleLayers.length; i++) {
|
||||
const merged = document.createElement('canvas');
|
||||
merged.width = state.imgWidth;
|
||||
merged.height = state.imgHeight;
|
||||
const baseCtx = merged.getContext('2d');
|
||||
for (let i = 0; i < visibleLayers.length; i++) {
|
||||
const l = visibleLayers[i];
|
||||
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
|
||||
baseCtx.globalAlpha = l.opacity;
|
||||
baseCtx.drawImage(l.canvas, off.x, off.y);
|
||||
baseCtx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
|
||||
baseCtx.globalAlpha = 1;
|
||||
}
|
||||
base.canvas = merged;
|
||||
base.ctx = base.canvas.getContext('2d');
|
||||
base.opacity = 1;
|
||||
base.visible = true;
|
||||
state.layerOffsets.set(base.id, { x: 0, y: 0 });
|
||||
_clearBakedAdjustments(base);
|
||||
// Free offset entries for the discarded layers; keep base.
|
||||
for (const l of state.layers) {
|
||||
if (l === base) continue;
|
||||
@@ -95,7 +133,7 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
const idx = state.layers.findIndex(l => l.id === state.activeLayerId);
|
||||
if (idx < 1) return; // can't merge the bottom layer
|
||||
saveState('Merge down');
|
||||
mergeLayerDownAtIndex(idx);
|
||||
mergeLayerDownAtIndex(idx, renderLayer);
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Layer merged down');
|
||||
|
||||
@@ -71,10 +71,15 @@ export function wireTopbar(deps) {
|
||||
// original IDs so the standalone handlers below wire to them
|
||||
// unchanged.
|
||||
{
|
||||
const saveBtn = document.getElementById('ge-save-menu-btn');
|
||||
const saveMenu = document.getElementById('ge-save-menu');
|
||||
const editorRoot = document.getElementById('gallery-editor-container') || document;
|
||||
const saveBtn = editorRoot.querySelector('#ge-save-menu-btn');
|
||||
const saveWrap = saveBtn?.closest('.ge-save-wrap');
|
||||
const saveMenu = saveWrap?.querySelector('#ge-save-menu');
|
||||
if (saveBtn && saveMenu) {
|
||||
const saveTopbar = saveBtn.closest('.ge-topbar');
|
||||
document.querySelectorAll('body > #ge-save-menu').forEach((menu) => {
|
||||
if (menu !== saveMenu) menu.remove();
|
||||
});
|
||||
// Reparent the menu to <body>. Without this, the menu inherits
|
||||
// the gallery modal's containing block (the modal applies a
|
||||
// `transform: scale(...)` for its enter animation — and any
|
||||
@@ -105,7 +110,7 @@ export function wireTopbar(deps) {
|
||||
saveMenu.addEventListener('click', () => { setSaveMenuOpen(false); });
|
||||
window.addEventListener('resize', () => { if (!saveMenu.hidden) positionSaveMenu(); });
|
||||
registerDocClickAway((e) => {
|
||||
if (!saveMenu.hidden && !saveMenu.contains(e.target) && e.target !== saveBtn) {
|
||||
if (!saveMenu.hidden && !saveMenu.contains(e.target) && !saveBtn.contains(e.target)) {
|
||||
setSaveMenuOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
+80
-37
@@ -5,7 +5,7 @@
|
||||
|
||||
import spinnerModule from './spinner.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
|
||||
@@ -112,21 +112,10 @@ function _cleanAiReplyText(text) {
|
||||
return t
|
||||
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
|
||||
.replace(/<<<\s*END\s*>>+/gi, '')
|
||||
.replace(/<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function _shouldUseFastAiReply(data) {
|
||||
const body = String(data?.body || data?.body_html || '');
|
||||
const subject = String(data?.subject || '');
|
||||
const atts = Array.isArray(data?.attachments) ? data.attachments : [];
|
||||
if (atts.length > 0) return false;
|
||||
const text = `${subject}\n${body}`.toLowerCase();
|
||||
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return body.length < 2500;
|
||||
}
|
||||
|
||||
let _emails = [];
|
||||
let _currentFolder = 'INBOX';
|
||||
let _offset = 0;
|
||||
@@ -202,6 +191,7 @@ export function init(documentModule) {
|
||||
}
|
||||
},
|
||||
});
|
||||
prewarmEmailLibrary({ delay: 1800 });
|
||||
_watchDocOpenToReDockEmail();
|
||||
}
|
||||
|
||||
@@ -348,7 +338,11 @@ async function _refreshUnreadCount() {
|
||||
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
|
||||
|
||||
// Only show dot if there's a new email above the threshold
|
||||
dot.style.display = maxUid > lastSeen ? '' : 'none';
|
||||
const hasNewUnread = maxUid > lastSeen;
|
||||
dot.style.display = hasNewUnread ? '' : 'none';
|
||||
if (hasNewUnread && !isLibOpen()) {
|
||||
prewarmUnreadEmails({ limit: Math.min(10, Math.max(1, unreadCount)), maxUid }).catch(() => {});
|
||||
}
|
||||
|
||||
// Color the dot by urgency tier. Cache the per-uid map so the per-row
|
||||
// renderer can reuse it without a second fetch.
|
||||
@@ -405,22 +399,29 @@ export async function loadEmails(append = false) {
|
||||
|
||||
try {
|
||||
const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
const applyListData = (data) => {
|
||||
if (!append) _emails = [];
|
||||
_emails.push(...(data.emails || []));
|
||||
_total = data.total || 0;
|
||||
|
||||
// Remove spinner
|
||||
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
|
||||
|
||||
_renderList();
|
||||
|
||||
const unreadCount = _emails.filter(e => !e.is_read).length;
|
||||
const dot = document.getElementById('email-unread-dot');
|
||||
if (dot) dot.style.display = unreadCount > 0 ? '' : 'none';
|
||||
};
|
||||
if (!append && !_senderFilter) {
|
||||
try {
|
||||
const cachedRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}&cached_only=1${_acct()}`);
|
||||
const cachedData = await cachedRes.json();
|
||||
if (!cachedData.error && (cachedData.emails || []).length) {
|
||||
applyListData(cachedData);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
applyListData(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to load emails:', e);
|
||||
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
|
||||
@@ -751,7 +752,7 @@ function _createEmailItem(em) {
|
||||
}
|
||||
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
|
||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||
// Body pre-fill from the agent's open_email_reply tool call takes the
|
||||
// same insertion slot as an AI-suggested body — both land just before
|
||||
@@ -786,8 +787,29 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
console.error('Failed to read email:', data.error);
|
||||
return;
|
||||
}
|
||||
// The list row is already populated from the durable email index. Some
|
||||
// IMAP/read paths can return a partial object for long Outlook threads;
|
||||
// never let that create a reply draft with blank To/Subject.
|
||||
const _fallback = (primary, fallback) => {
|
||||
const p = primary == null ? '' : String(primary).trim();
|
||||
if (p) return primary;
|
||||
return fallback == null ? '' : fallback;
|
||||
};
|
||||
data = {
|
||||
...em,
|
||||
...data,
|
||||
uid: data.uid || em.uid,
|
||||
subject: _fallback(data.subject, em.subject),
|
||||
from_name: _fallback(data.from_name, em.from_name || em.from_address),
|
||||
from_address: _fallback(data.from_address, em.from_address),
|
||||
to: _fallback(data.to, em.to),
|
||||
cc: _fallback(data.cc, em.cc),
|
||||
date: _fallback(data.date, em.date),
|
||||
message_id: _fallback(data.message_id, em.message_id),
|
||||
};
|
||||
if (wantsAiReply) {
|
||||
if (data.cached_ai_reply) {
|
||||
const activeReplyAccount = data.account_id || em.account_id || window.__odysseusActiveEmailAccount || '';
|
||||
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
||||
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
||||
} else {
|
||||
let draftToastTimer = null;
|
||||
@@ -813,7 +835,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
message_id: data.message_id || '',
|
||||
uid: String(em.uid || ''),
|
||||
folder: _currentFolder,
|
||||
fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
|
||||
account_id: activeReplyAccount,
|
||||
fast: true,
|
||||
user_hint: (noteHint || '').trim() || undefined,
|
||||
}),
|
||||
});
|
||||
@@ -880,6 +903,9 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
let _baseSubject = (data.subject || '').trim();
|
||||
if (subjectPrefix === 'Re: ' && /^re\s*:/i.test(_baseSubject)) subjectPrefix = '';
|
||||
else if (subjectPrefix === 'Fwd: ' && /^fwd?\s*:/i.test(_baseSubject)) subjectPrefix = '';
|
||||
if (mode !== 'forward' && !String(toAddress || '').trim()) {
|
||||
throw new Error('Cannot create reply: sender address is missing from this email.');
|
||||
}
|
||||
let content = `To: ${toAddress}\nSubject: ${subjectPrefix}${_baseSubject}`;
|
||||
if (ccAddresses) content += `\nCc: ${ccAddresses}`;
|
||||
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
|
||||
@@ -949,9 +975,10 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
|
||||
if (_docModule) {
|
||||
// Agent-provided reply text should land in the email draft the user
|
||||
// already has open. Otherwise mobile users see the source email while the
|
||||
// agent silently creates a second draft elsewhere.
|
||||
const reuseExisting = mode !== 'forward';
|
||||
// already has open. Plain Reply clicks must create a fresh draft: reusing
|
||||
// old source-UID drafts can reopen stale quote-only/malformed compose docs
|
||||
// and block Send on long threads.
|
||||
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
|
||||
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
||||
? _docModule.findEmailDocId(em.uid, _currentFolder)
|
||||
: null;
|
||||
@@ -959,28 +986,37 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (!_docModule.isPanelOpen()) _docModule.openPanel();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
await _docModule.loadDocument(existingDocId);
|
||||
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
|
||||
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
|
||||
}
|
||||
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: true });
|
||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
|
||||
}
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
} else {
|
||||
const activeSid = await _createEmailChat(data);
|
||||
let activeSid = await _createEmailChat(data, { forceNew: true });
|
||||
if (!activeSid) {
|
||||
console.error('reply: could not obtain a session_id');
|
||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const docRes = await fetch(`${API_BASE}/api/document`, {
|
||||
const createReplyDoc = (sessionId) => fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: activeSid,
|
||||
session_id: sessionId,
|
||||
title: data.subject,
|
||||
content: content,
|
||||
language: 'email',
|
||||
}),
|
||||
});
|
||||
let docRes = await createReplyDoc(activeSid);
|
||||
if (docRes.status === 404) {
|
||||
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
|
||||
activeSid = await _createEmailChat(data, { forceNew: true });
|
||||
if (activeSid) docRes = await createReplyDoc(activeSid);
|
||||
}
|
||||
if (!docRes.ok) {
|
||||
const errText = await docRes.text();
|
||||
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
|
||||
@@ -1255,9 +1291,10 @@ async function _toggleDone(em, itemEl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _createEmailChat(emailData) {
|
||||
async function _createEmailChat(emailData, opts = {}) {
|
||||
const subject = String(emailData?.subject || 'New Email').trim() || 'New Email';
|
||||
const title = subject === 'New Email' ? 'New Email' : `Email: ${subject.slice(0, 60)}`;
|
||||
const forceNew = !!opts.forceNew;
|
||||
try {
|
||||
const currentSid = sessionModule.getCurrentSessionId?.() || '';
|
||||
const current = sessionModule.getSessions?.().find(s => s.id === currentSid);
|
||||
@@ -1268,7 +1305,7 @@ async function _createEmailChat(emailData) {
|
||||
&& Number(current.message_count || 0) === 0
|
||||
&& current.folder !== 'Assistant'
|
||||
&& current.folder !== 'Tasks';
|
||||
if (currentIsBlank) {
|
||||
if (!forceNew && currentIsBlank) {
|
||||
const meta = document.getElementById('current-meta');
|
||||
if (meta) meta.textContent = title;
|
||||
return current.id;
|
||||
@@ -1319,22 +1356,28 @@ async function _composeNew() {
|
||||
// (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
|
||||
// after the session + doc exist.
|
||||
try {
|
||||
const sid = await _createEmailChat({ subject: 'New Email' });
|
||||
let sid = await _createEmailChat({ subject: 'New Email' });
|
||||
if (!sid) {
|
||||
console.error('compose: could not obtain a session_id');
|
||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/document`, {
|
||||
const createComposeDoc = (sessionId) => fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sid,
|
||||
session_id: sessionId,
|
||||
title: 'New Email',
|
||||
content: 'To: \nSubject: \n---\n',
|
||||
language: 'email',
|
||||
}),
|
||||
});
|
||||
let res = await createComposeDoc(sid);
|
||||
if (res.status === 404) {
|
||||
console.warn('[compose-debug] draft session rejected; retrying in a fresh email chat', sid);
|
||||
sid = await _createEmailChat({ subject: 'New Email' }, { forceNew: true });
|
||||
if (sid) res = await createComposeDoc(sid);
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.error('compose POST failed', res.status, await res.text().catch(() => ''));
|
||||
import('./ui.js').then(m => m.showError && m.showError('Failed to create new email (' + res.status + ')')).catch(() => {});
|
||||
|
||||
+1440
-719
File diff suppressed because it is too large
Load Diff
@@ -356,14 +356,14 @@ export async function uploadPending(opts = {}) {
|
||||
/**
|
||||
* Add files to pending list (capped at MAX_FILES)
|
||||
*/
|
||||
export async function addFiles(files) {
|
||||
export async function addFiles(files, opts = {}) {
|
||||
for (const f of files) {
|
||||
if (pendingFiles.length >= MAX_FILES) {
|
||||
_showToast(`Max ${MAX_FILES} files allowed`);
|
||||
break;
|
||||
}
|
||||
let nextFile = f;
|
||||
if (_isMobileViewport() && _isCroppableImage(f)) {
|
||||
if (!opts.skipCrop && _isMobileViewport() && _isCroppableImage(f)) {
|
||||
try {
|
||||
nextFile = await _openMobileCropper(f);
|
||||
} catch (_) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
+471
-13
@@ -54,12 +54,12 @@ import {
|
||||
buildThumbnail as _buildThumbnailImpl,
|
||||
buildMergedMaskCanvas as _buildMergedMaskCanvasImpl,
|
||||
} from './editor/composite-helpers.js';
|
||||
import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js';
|
||||
import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js?v=20260708sam3';
|
||||
import { buildTopbar as _buildTopbar } from './editor/build/topbar.js';
|
||||
import {
|
||||
controlsHTML as _controlsHTML,
|
||||
layerPanelHTML as _layerPanelHTML,
|
||||
} from './editor/build/controls.js';
|
||||
} from './editor/build/controls.js?v=20260708match1';
|
||||
import {
|
||||
transformPopupHTML as _transformPopupHTML,
|
||||
attachSpinRepeat as _attachSpinRepeat,
|
||||
@@ -96,14 +96,14 @@ import { createShortcutsPopover } from './editor/shortcuts-popover.js';
|
||||
import { wireKeyboardShortcuts } from './editor/keyboard-shortcuts.js';
|
||||
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
|
||||
import { wireAIModelSelectors } from './editor/ai-models.js';
|
||||
import { wireInpaintButtons } from './editor/ai-inpaint.js';
|
||||
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
||||
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
||||
import { wireRembgAndSharpen } from './editor/ai-rembg.js';
|
||||
import { wireStrokeToolSliders } from './editor/stroke-tool-sliders.js';
|
||||
import { wireImport } from './editor/wire-import.js';
|
||||
import { wireMergeButtons } from './editor/wire-merge-buttons.js';
|
||||
import { wireSelectionControls } from './editor/wire-selection-controls.js';
|
||||
import { wireInpaintControls } from './editor/wire-inpaint-controls.js';
|
||||
import { wireInpaintControls } from './editor/wire-inpaint-controls.js?v=20260708match1';
|
||||
import { wireTopbar, closeOtherTopbarMenus as _closeOtherTopbarMenus } from './editor/wire-topbar.js';
|
||||
import { wireTopbarOverflow } from './editor/wire-topbar-overflow.js';
|
||||
import { wireTopbarMenus } from './editor/wire-topbar-menus.js';
|
||||
@@ -125,6 +125,14 @@ function _syncTransformOverlay() { _syncTransformOverlayImpl(_TRANSFORM_OVERLAY_
|
||||
// the inpaint tool for the first time in this editor session we bump
|
||||
// the slider to this value (without touching other tools).
|
||||
const _INPAINT_DEFAULT_BRUSH = 100;
|
||||
let _samAbortController = null;
|
||||
|
||||
function _cancelSamQuery(showToast = true) {
|
||||
if (!_samAbortController) return false;
|
||||
try { _samAbortController.abort(); } catch {}
|
||||
if (showToast && uiModule) uiModule.showToast('SAM query cancelled');
|
||||
return true;
|
||||
}
|
||||
|
||||
function _galleryEditMounted() {
|
||||
return !!document.querySelector('#gallery-editor-container .gallery-editor');
|
||||
@@ -133,6 +141,15 @@ function _galleryEditMounted() {
|
||||
if (!window.__galleryEditEscHardGuardInstalled) {
|
||||
window.__galleryEditEscHardGuardInstalled = true;
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const isSamCancel = !!_samAbortController
|
||||
&& (e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'c'));
|
||||
if (isSamCancel) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
_cancelSamQuery();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Escape') return;
|
||||
if (window.__galleryEditLive || _galleryEditMounted()) {
|
||||
e.preventDefault();
|
||||
@@ -272,6 +289,16 @@ function _setAiCommandStatus(text, kind = '') {
|
||||
el.dataset.kind = kind || '';
|
||||
}
|
||||
|
||||
function _escapeAiCommandText(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function _clickToolButton(toolId) {
|
||||
const btn = state.container?.querySelector(`.ge-tool-btn[data-tool="${toolId}"]`);
|
||||
if (btn) btn.click();
|
||||
@@ -288,6 +315,21 @@ function _runExistingButton(id, status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function _openSamPrompt() {
|
||||
if (state.tool !== 'sam') {
|
||||
_clickToolButton('sam');
|
||||
} else {
|
||||
const controls = document.getElementById('ge-controls') || document.querySelector('.ge-controls');
|
||||
controls?.classList.remove('dismissed');
|
||||
document.getElementById('ge-sam-section')?.style.removeProperty('display');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const input = document.getElementById('ge-sam-query');
|
||||
input?.focus();
|
||||
input?.select?.();
|
||||
});
|
||||
}
|
||||
|
||||
function _buildAiCommandBox() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ge-ai-command ge-ai-command-collapsed';
|
||||
@@ -295,7 +337,10 @@ function _buildAiCommandBox() {
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="ge-ai-command-toggle" id="ge-ai-command-toggle" aria-expanded="false">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
<span>AI Edit</span>
|
||||
<span>Quick Edit</span>
|
||||
<svg class="ge-ai-command-toggle-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 15 12 9 18 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<form class="ge-ai-command-form" id="ge-ai-command-form">
|
||||
<input type="text" class="ge-ai-command-input" id="ge-ai-command-input" autocomplete="off" />
|
||||
@@ -305,18 +350,49 @@ function _buildAiCommandBox() {
|
||||
<polyline points="5 12 12 5 19 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="ge-ai-command-close" id="ge-ai-command-close" title="Close AI edit" aria-label="Close AI edit">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
<button type="button" class="ge-ai-command-close" id="ge-ai-command-close" title="Collapse AI edit" aria-label="Collapse AI edit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="ge-ai-command-suggestions" id="ge-ai-command-suggestions" hidden></div>
|
||||
<div class="ge-ai-command-status" id="ge-ai-command-status" aria-live="polite"></div>
|
||||
`;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const _AI_COMMAND_SUGGESTIONS = [
|
||||
{ label: 'Rotate 90', insert: 'rotate 90', hint: 'Turn the image clockwise', aliases: ['ro', 'rotate', 'right', 'clockwise', 'turn'] },
|
||||
{ label: 'Rotate left', insert: 'rotate left', hint: 'Turn the image counter-clockwise', aliases: ['rotate left', 'left', 'counter clockwise', 'ccw'] },
|
||||
{ label: 'Rotate 180', insert: 'rotate 180', hint: 'Flip the canvas upside down', aliases: ['rotate 180', 'upside down'] },
|
||||
{ label: 'Flip horizontal', insert: 'flip horizontal', hint: 'Mirror left to right', aliases: ['flip', 'mirror', 'horizontal'] },
|
||||
{ label: 'Flip vertical', insert: 'flip vertical', hint: 'Mirror top to bottom', aliases: ['flip vertical', 'vertical'] },
|
||||
{ label: 'Remove background', insert: 'remove background', hint: 'Make the background transparent', aliases: ['remove bg', 'background', 'transparent', 'cut out'] },
|
||||
{ label: 'Upscale', insert: 'upscale 2x', hint: 'Increase image resolution', aliases: ['upscale', 'bigger', 'larger', '2x', '4x'] },
|
||||
{ label: 'Denoise', insert: 'denoise', hint: 'Reduce grain and noise', aliases: ['denoise', 'noise', 'grain', 'clean up'] },
|
||||
{ label: 'Sharpen', insert: 'sharpen', hint: 'Make details crisper', aliases: ['sharpen', 'sharp', 'clearer', 'crisp', 'enhance'] },
|
||||
{ label: 'Enhance face', insert: 'enhance face', hint: 'Restore portrait and skin detail', aliases: ['face', 'portrait', 'skin', 'selfie', 'restore'] },
|
||||
{ label: 'Style edit', insert: 'style: ', hint: 'Run a full-image prompt edit', aliases: ['style', 'paint', 'anime', 'photo', 'prompt'] },
|
||||
];
|
||||
|
||||
function _matchAiCommandSuggestions(query) {
|
||||
const q = (query || '').trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return _AI_COMMAND_SUGGESTIONS
|
||||
.map((item) => {
|
||||
const hay = [item.label, item.insert, ...(item.aliases || [])].map(v => String(v || '').toLowerCase());
|
||||
const starts = hay.some(v => v.startsWith(q));
|
||||
const contains = hay.some(v => v.includes(q));
|
||||
if (!starts && !contains) return null;
|
||||
return { item, score: starts ? 0 : 1 };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label))
|
||||
.slice(0, 6)
|
||||
.map(hit => hit.item);
|
||||
}
|
||||
|
||||
function _wireAiCommandBox() {
|
||||
const wrap = document.getElementById('ge-ai-command');
|
||||
const toggle = document.getElementById('ge-ai-command-toggle');
|
||||
@@ -324,18 +400,91 @@ function _wireAiCommandBox() {
|
||||
const form = document.getElementById('ge-ai-command-form');
|
||||
const input = document.getElementById('ge-ai-command-input');
|
||||
const runBtn = document.getElementById('ge-ai-command-run');
|
||||
const suggestions = document.getElementById('ge-ai-command-suggestions');
|
||||
if (!wrap || !form || !input || !runBtn) return;
|
||||
let suggestionItems = [];
|
||||
let suggestionIndex = 0;
|
||||
const hideSuggestions = () => {
|
||||
suggestionItems = [];
|
||||
suggestionIndex = 0;
|
||||
if (suggestions) {
|
||||
suggestions.hidden = true;
|
||||
suggestions.innerHTML = '';
|
||||
}
|
||||
};
|
||||
const renderSuggestions = () => {
|
||||
if (!suggestions || wrap.classList.contains('ge-ai-command-collapsed')) return;
|
||||
suggestionItems = _matchAiCommandSuggestions(input.value);
|
||||
suggestionIndex = Math.min(suggestionIndex, Math.max(0, suggestionItems.length - 1));
|
||||
if (!suggestionItems.length) {
|
||||
hideSuggestions();
|
||||
return;
|
||||
}
|
||||
suggestions.hidden = false;
|
||||
suggestions.innerHTML = suggestionItems.map((item, idx) => `
|
||||
<button type="button" class="ge-ai-command-suggestion${idx === suggestionIndex ? ' active' : ''}" data-ai-command-suggestion="${idx}">
|
||||
<span class="ge-ai-command-suggestion-main">${_escapeAiCommandText(item.label)}</span>
|
||||
<span class="ge-ai-command-suggestion-hint">${_escapeAiCommandText(item.hint || item.insert)}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
};
|
||||
const pickSuggestion = (idx, run = false) => {
|
||||
const item = suggestionItems[idx];
|
||||
if (!item) return false;
|
||||
input.value = item.insert;
|
||||
hideSuggestions();
|
||||
input.focus();
|
||||
if (run) form.requestSubmit();
|
||||
return true;
|
||||
};
|
||||
wrap.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
wrap.addEventListener('click', (e) => e.stopPropagation());
|
||||
suggestions?.addEventListener('pointerdown', (e) => e.preventDefault());
|
||||
suggestions?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-ai-command-suggestion]');
|
||||
if (!btn) return;
|
||||
pickSuggestion(Number(btn.dataset.aiCommandSuggestion), true);
|
||||
});
|
||||
const setOpen = (open) => {
|
||||
wrap.classList.toggle('ge-ai-command-collapsed', !open);
|
||||
toggle?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (open) requestAnimationFrame(() => input.focus());
|
||||
if (open) requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
renderSuggestions();
|
||||
});
|
||||
else hideSuggestions();
|
||||
};
|
||||
toggle?.addEventListener('click', () => setOpen(wrap.classList.contains('ge-ai-command-collapsed')));
|
||||
closeBtn?.addEventListener('click', () => setOpen(false));
|
||||
input.addEventListener('input', renderSuggestions);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const open = suggestions && !suggestions.hidden && suggestionItems.length;
|
||||
if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault();
|
||||
suggestionIndex = e.key === 'ArrowDown'
|
||||
? (suggestionIndex + 1) % suggestionItems.length
|
||||
: (suggestionIndex - 1 + suggestionItems.length) % suggestionItems.length;
|
||||
renderSuggestions();
|
||||
return;
|
||||
}
|
||||
if (open && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickSuggestion(suggestionIndex, true);
|
||||
return;
|
||||
}
|
||||
if (open && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
pickSuggestion(suggestionIndex, false);
|
||||
return;
|
||||
}
|
||||
if (open && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideSuggestions();
|
||||
}
|
||||
});
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideSuggestions();
|
||||
const prompt = input.value.trim();
|
||||
if (!prompt) {
|
||||
_setAiCommandStatus('Type what you want changed.', 'error');
|
||||
@@ -344,6 +493,36 @@ function _wireAiCommandBox() {
|
||||
}
|
||||
const p = prompt.toLowerCase();
|
||||
try {
|
||||
if (/\brotate\b.*\b180\b|\bupside\s*down\b/.test(p)) {
|
||||
_saveState('Rotate 180');
|
||||
_rotateAllLayers(180);
|
||||
_setAiCommandStatus('Rotated 180.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\brotate\b.*\b(left|ccw|counter)\b|\bturn\s+left\b/.test(p)) {
|
||||
_saveState('Rotate left');
|
||||
_rotateAllLayers(270);
|
||||
_setAiCommandStatus('Rotated left.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\brotate\b|\bturn\s+right\b|\bclockwise\b/.test(p)) {
|
||||
_saveState('Rotate 90');
|
||||
_rotateAllLayers(90);
|
||||
_setAiCommandStatus('Rotated 90.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\bflip\b.*\b(vertical|v)\b|\bmirror\b.*\b(vertical|v)\b/.test(p)) {
|
||||
_saveState('Flip vertical');
|
||||
_flipAllLayers('v');
|
||||
_setAiCommandStatus('Flipped vertical.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\bflip\b|\bmirror\b/.test(p)) {
|
||||
_saveState('Flip horizontal');
|
||||
_flipAllLayers('h');
|
||||
_setAiCommandStatus('Flipped horizontal.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\b(remove|erase|cut\s*out|transparent)\b.*\b(bg|background)\b|\b(bg|background)\b.*\b(remove|erase|transparent)\b/.test(p)) {
|
||||
_clickToolButton('rembg');
|
||||
_runExistingButton('ge-rembg-run', 'Removing background...');
|
||||
@@ -1220,6 +1399,7 @@ function _beginDraw(e) {
|
||||
// it doesn't mutate the layer until an action (Erase/Copy) is taken.
|
||||
// Full implementation in editor/tools/wand.js.
|
||||
if (state.tool === 'wand') return _wandTool.click(e);
|
||||
if (state.tool === 'sam') return _runSamSelection(e);
|
||||
// Inpaint can create its own layer + mask on the fly, so skip the
|
||||
// "no active layer → bail" gate for it specifically.
|
||||
if (state.tool !== 'inpaint' && (!layer || layer.locked)) return;
|
||||
@@ -1740,6 +1920,146 @@ function _runMagicWand(cx, cy, mode = 'replace', opts = {}) {
|
||||
_syncToolClearIndicators();
|
||||
}
|
||||
|
||||
async function _runSamSelection(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) {
|
||||
if (uiModule) uiModule.showToast('Select an unlocked layer');
|
||||
return;
|
||||
}
|
||||
const coords = _canvasCoords(e, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const lx = Math.floor(coords.x - off.x);
|
||||
const ly = Math.floor(coords.y - off.y);
|
||||
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return;
|
||||
|
||||
let mode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) mode = 'add';
|
||||
else if (e.altKey) mode = 'subtract';
|
||||
|
||||
_cancelSamQuery(false);
|
||||
const controller = new AbortController();
|
||||
_samAbortController = controller;
|
||||
const cleanup = _showWandLoading();
|
||||
try {
|
||||
await _requestAndApplySamMask(layer, {
|
||||
points: [{ x: lx, y: ly, label: 1 }],
|
||||
}, mode, { x: coords.x, y: coords.y }, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError' && uiModule) {
|
||||
uiModule.showToast(err.message || String(err), 7000);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
if (_samAbortController === controller) _samAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _runSamTextSelection() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) {
|
||||
if (uiModule) uiModule.showToast('Select an unlocked layer');
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('ge-sam-query');
|
||||
const text = (input?.value || '').trim();
|
||||
if (!text) {
|
||||
if (uiModule) uiModule.showToast('Type an object to find');
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('ge-sam-find');
|
||||
const old = btn?.innerHTML;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>Finding…';
|
||||
}
|
||||
_cancelSamQuery(false);
|
||||
const controller = new AbortController();
|
||||
_samAbortController = controller;
|
||||
const cleanup = _showWandLoading();
|
||||
try {
|
||||
await _requestAndApplySamMask(layer, { text }, state.wandMode || 'replace', null, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError' && uiModule) {
|
||||
uiModule.showToast(err.message || String(err), 7000);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
if (_samAbortController === controller) _samAbortController = null;
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = old || '<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>Find';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _requestAndApplySamMask(layer, payload, mode, seedPoint, opts = {}) {
|
||||
const res = await fetch('/api/image/mask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: opts.signal,
|
||||
body: JSON.stringify({
|
||||
image: layer.canvas.toDataURL('image/png').split(',')[1],
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.mask) {
|
||||
throw new Error(data.detail || data.error || `Mask failed (${res.status})`);
|
||||
}
|
||||
if (!data.bbox) {
|
||||
throw new Error(data.grounding ? `Found ${data.grounding.label || 'object'}, but SAM returned an empty mask` : 'SAM returned an empty mask');
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error('Failed to decode mask'));
|
||||
img.src = 'data:image/png;base64,' + data.mask;
|
||||
});
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = layer.canvas.width;
|
||||
mask.height = layer.canvas.height;
|
||||
const mctx = mask.getContext('2d');
|
||||
mctx.drawImage(img, 0, 0, mask.width, mask.height);
|
||||
const maskData = mctx.getImageData(0, 0, mask.width, mask.height);
|
||||
const md = maskData.data;
|
||||
for (let i = 0; i < md.length; i += 4) {
|
||||
const alpha = md[i]; // server mask is white selected / black unselected
|
||||
md[i] = 255;
|
||||
md[i + 1] = 255;
|
||||
md[i + 2] = 255;
|
||||
md[i + 3] = alpha;
|
||||
}
|
||||
mctx.putImageData(maskData, 0, 0);
|
||||
|
||||
_saveState();
|
||||
const compatible = state.wandMask && state.wandLayerId === layer.id &&
|
||||
state.wandMask.width === mask.width && state.wandMask.height === mask.height;
|
||||
if (compatible && mode === 'add') {
|
||||
state.wandMask.getContext('2d').drawImage(mask, 0, 0);
|
||||
} else if (compatible && mode === 'subtract') {
|
||||
const ec = state.wandMask.getContext('2d');
|
||||
ec.save();
|
||||
ec.globalCompositeOperation = 'destination-out';
|
||||
ec.drawImage(mask, 0, 0);
|
||||
ec.restore();
|
||||
} else {
|
||||
state.wandMask = mask;
|
||||
state.wandLayerId = layer.id;
|
||||
}
|
||||
state.wandLastSeed = seedPoint
|
||||
? { x: seedPoint.x, y: seedPoint.y, mode, source: 'sam' }
|
||||
: { x: 0, y: 0, mode, source: 'sam-text' };
|
||||
state.wandMaskVisible = true;
|
||||
composite();
|
||||
_syncToolClearIndicators();
|
||||
if (data.grounding && uiModule) {
|
||||
const pct = Math.round((data.grounding.score || 0) * 100);
|
||||
uiModule.showToast(`Selected ${data.grounding.label || 'object'}${pct ? ` (${pct}%)` : ''}`, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
function _showWandLoading() {
|
||||
const area = state.container?.querySelector('.ge-canvas-area');
|
||||
if (!area) return () => {};
|
||||
@@ -1981,12 +2301,108 @@ function _wandToMask() {
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandLastSeed = null;
|
||||
mask.visible = true;
|
||||
layer.activeMaskId = mask.id;
|
||||
state.maskVisible = true;
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast('Selection added to mask');
|
||||
}
|
||||
|
||||
// Reveal/hide the small "X" badge on the Lasso and Wand tool buttons
|
||||
function _autoMatchLastInpaintLayer() {
|
||||
const layer = state.layers.find(l => l.id === state.lastInpaintLayerId);
|
||||
const src = layer?.inpaintSource;
|
||||
if (!layer || !src?.base || !src?.mask) {
|
||||
if (uiModule) uiModule.showToast('Run inpaint first');
|
||||
return;
|
||||
}
|
||||
const w = state.imgWidth;
|
||||
const h = state.imgHeight;
|
||||
let baseData, resultData, maskData;
|
||||
try {
|
||||
baseData = src.base.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
resultData = layer.canvas.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
maskData = src.mask.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
} catch (err) {
|
||||
if (uiModule) uiModule.showToast('Auto match failed: cannot read pixels');
|
||||
return;
|
||||
}
|
||||
|
||||
const inside = { r: 0, g: 0, b: 0, y: 0, n: 0 };
|
||||
const outside = { r: 0, g: 0, b: 0, y: 0, n: 0 };
|
||||
const step = Math.max(1, Math.round(Math.max(w, h) / 900));
|
||||
const radius = Math.max(2, Math.round(Math.min(w, h) * 0.006));
|
||||
const sample = (bucket, data, idx) => {
|
||||
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
|
||||
bucket.r += r; bucket.g += g; bucket.b += b;
|
||||
bucket.y += 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
bucket.n++;
|
||||
};
|
||||
const isMasked = (x, y) => {
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) return false;
|
||||
return maskData[(y * w + x) * 4 + 3] > 24;
|
||||
};
|
||||
for (let y = radius; y < h - radius; y += step) {
|
||||
for (let x = radius; x < w - radius; x += step) {
|
||||
const idx = (y * w + x) * 4;
|
||||
const m = maskData[idx + 3] > 24;
|
||||
let touchesOther = false;
|
||||
for (let dy = -radius; dy <= radius && !touchesOther; dy += radius) {
|
||||
for (let dx = -radius; dx <= radius; dx += radius) {
|
||||
if (!dx && !dy) continue;
|
||||
if (isMasked(x + dx, y + dy) !== m) {
|
||||
touchesOther = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!touchesOther) continue;
|
||||
if (m && resultData[idx + 3] > 24) sample(inside, resultData, idx);
|
||||
else if (!m && baseData[idx + 3] > 24) sample(outside, baseData, idx);
|
||||
}
|
||||
}
|
||||
if (inside.n < 20 || outside.n < 20) {
|
||||
if (uiModule) uiModule.showToast('Auto match needs a larger mask edge');
|
||||
return;
|
||||
}
|
||||
for (const b of [inside, outside]) {
|
||||
b.r /= b.n; b.g /= b.n; b.b /= b.n; b.y /= b.n;
|
||||
}
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const dr = clamp((outside.r - inside.r) * 0.55, -55, 55);
|
||||
const dg = clamp((outside.g - inside.g) * 0.55, -55, 55);
|
||||
const db = clamp((outside.b - inside.b) * 0.55, -55, 55);
|
||||
const dy = clamp((outside.y - inside.y) * 0.35, -35, 35);
|
||||
if (!layer.adjLayers) layer.adjLayers = [];
|
||||
layer.adjLayers = layer.adjLayers.filter(a => a.id !== 'auto-match-color' && a.id !== 'auto-match-light');
|
||||
layer.adjLayers.push({
|
||||
id: 'auto-match-light',
|
||||
type: 'brightness-contrast',
|
||||
params: {
|
||||
brightness: clamp(1 + (dy / 255), 0.75, 1.25),
|
||||
contrast: 1,
|
||||
},
|
||||
opacity: 0.7,
|
||||
visible: true,
|
||||
});
|
||||
layer.adjLayers.push({
|
||||
id: 'auto-match-color',
|
||||
type: 'color-balance',
|
||||
params: {
|
||||
shadows: { r: dr * 0.45, g: dg * 0.45, b: db * 0.45 },
|
||||
midtones: { r: dr, g: dg, b: db },
|
||||
highlights: { r: dr * 0.35, g: dg * 0.35, b: db * 0.35 },
|
||||
},
|
||||
opacity: 0.75,
|
||||
visible: true,
|
||||
});
|
||||
_saveState('Auto match inpaint color');
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast('Auto matched color');
|
||||
}
|
||||
|
||||
// Reveal/hide the small "X" badge on the Lasso, Wand, and SAM tool buttons
|
||||
// based on whether each tool currently holds a selection. Called from
|
||||
// anywhere selection state mutates (wand click, lasso close, undo, etc.).
|
||||
function _syncToolClearIndicators() {
|
||||
@@ -2026,9 +2442,11 @@ function _syncToolClearIndicators() {
|
||||
if (!state.container) return;
|
||||
const lassoBtn = state.container.querySelector('.ge-tool-btn[data-tool="lasso"]');
|
||||
const wandBtn = state.container.querySelector('.ge-tool-btn[data-tool="wand"]');
|
||||
const samBtn = state.container.querySelector('.ge-tool-btn[data-tool="sam"]');
|
||||
const inpaintBtn = state.container.querySelector('.ge-tool-btn[data-tool="inpaint"]');
|
||||
if (lassoBtn) lassoBtn.classList.toggle('has-selection', state.lassoPoints.length >= 3 && !state.lassoActive);
|
||||
if (wandBtn) wandBtn.classList.toggle('has-selection', !!state.wandMask);
|
||||
if (samBtn) samBtn.classList.toggle('has-selection', !!state.wandMask);
|
||||
// Inpaint no longer carries a clear-X badge; masks live as sub-layers
|
||||
// in the layer panel and are deleted from there.
|
||||
if (inpaintBtn) inpaintBtn.classList.remove('has-selection');
|
||||
@@ -2470,6 +2888,7 @@ function _wireInpaintPopoverWindow() {
|
||||
// ── Build DOM ──
|
||||
|
||||
function _buildEditor(container) {
|
||||
document.querySelectorAll('body > #ge-save-menu').forEach(el => el.remove());
|
||||
container.innerHTML = '';
|
||||
container.className = 'gallery-editor';
|
||||
|
||||
@@ -2484,6 +2903,9 @@ function _buildEditor(container) {
|
||||
composite();
|
||||
} else if (which === 'wand') {
|
||||
_wandClear();
|
||||
} else if (which === 'sam') {
|
||||
_openSamPrompt();
|
||||
return;
|
||||
}
|
||||
_syncToolClearIndicators();
|
||||
},
|
||||
@@ -2505,7 +2927,7 @@ function _buildEditor(container) {
|
||||
// panel auto-minimises the layers sheet so the controls aren't
|
||||
// covered. Swiping the layers handle back up restores it.
|
||||
const isMobile = window.innerWidth <= 820;
|
||||
const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint'].includes(toolId);
|
||||
const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint', 'sam'].includes(toolId);
|
||||
const controlsVisible = controls && !controls.classList.contains('dismissed');
|
||||
if (isMobile && hasToolControls && controlsVisible) {
|
||||
const rp = document.querySelector('.ge-right-panel');
|
||||
@@ -2540,6 +2962,8 @@ function _buildEditor(container) {
|
||||
if (lassoSection) lassoSection.style.display = state.tool === 'lasso' ? '' : 'none';
|
||||
const wandSection = document.getElementById('ge-wand-section');
|
||||
if (wandSection) wandSection.style.display = state.tool === 'wand' ? '' : 'none';
|
||||
const samSection = document.getElementById('ge-sam-section');
|
||||
if (samSection) samSection.style.display = state.tool === 'sam' ? '' : 'none';
|
||||
const inpaintSection = document.getElementById('ge-inpaint-section');
|
||||
if (inpaintSection) {
|
||||
if (state.tool === 'inpaint') {
|
||||
@@ -2561,6 +2985,13 @@ function _buildEditor(container) {
|
||||
// Generate cleared it, but on re-entry the user expects to see
|
||||
// their mask again.
|
||||
if (state.tool === 'inpaint') {
|
||||
// If the user just made a SAM/Wand selection and then moves to
|
||||
// Inpaint, do the obvious thing: bake that selection into the
|
||||
// inpaint mask. Otherwise Generate says "draw the area first"
|
||||
// even though a red selection is visible on screen.
|
||||
if (state.wandMask && state.wandLayerId) {
|
||||
_wandToMask();
|
||||
}
|
||||
// First inpaint entry per session: bump the brush size to the
|
||||
// mask-friendly default (other tools keep their own size).
|
||||
if (!state.inpaintBrushInitialised) {
|
||||
@@ -2923,6 +3354,7 @@ function _buildEditor(container) {
|
||||
wireInpaintControls({
|
||||
composite,
|
||||
applyInpaintFeather: _applyInpaintFeather,
|
||||
autoMatchInpaint: _autoMatchLastInpaintLayer,
|
||||
syncToolClearIndicators: () => _syncToolClearIndicators(),
|
||||
attachColorPicker,
|
||||
uiModule,
|
||||
@@ -3008,6 +3440,27 @@ function _buildEditor(container) {
|
||||
applyImageTool: _applyImageTool,
|
||||
uiModule,
|
||||
});
|
||||
document.getElementById('ge-sam-find')?.addEventListener('click', () => _runSamTextSelection());
|
||||
document.getElementById('ge-sam-query')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_runSamTextSelection();
|
||||
}
|
||||
});
|
||||
document.getElementById('ge-sam-clear')?.addEventListener('click', () => _wandClear());
|
||||
document.getElementById('ge-sam-mask')?.addEventListener('click', () => _wandToMask());
|
||||
document.getElementById('ge-sam-vis')?.addEventListener('click', () => {
|
||||
state.wandMaskVisible = !state.wandMaskVisible;
|
||||
const btn = document.getElementById('ge-sam-vis');
|
||||
if (btn) {
|
||||
btn.innerHTML = state.wandMaskVisible
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20C5 20 1 12 1 12a20.29 20.29 0 0 1 5.06-5.94"/><path d="M9.9 4.24A10.45 10.45 0 0 1 12 4c7 0 11 8 11 8a20.65 20.65 0 0 1-2.16 3.19"/><path d="M14.12 14.12A3 3 0 0 1 9.88 9.88"/><path d="M1 1l22 22"/></svg>';
|
||||
btn.title = state.wandMaskVisible ? 'Hide selection overlay' : 'Show selection overlay';
|
||||
btn.classList.toggle('visible', state.wandMaskVisible);
|
||||
}
|
||||
composite();
|
||||
});
|
||||
_wireAiCommandBox();
|
||||
|
||||
// Merge / Flatten buttons (layer-panel footer) — full
|
||||
@@ -3017,6 +3470,7 @@ function _buildEditor(container) {
|
||||
createLayer,
|
||||
renderLayerPanel: () => _renderLayerPanel(),
|
||||
composite,
|
||||
renderLayer: (layer) => _renderLayerWithAdjLayers(layer),
|
||||
uiModule,
|
||||
});
|
||||
|
||||
@@ -3107,6 +3561,7 @@ const _layerPanelRenderer = createLayerPanelRenderer({
|
||||
openFxPopup: (layer, anchor) => _openFxPopup(layer, anchor),
|
||||
editAdjLayer: (layer, adj, anchor) => _editAdjLayer(layer, adj, anchor),
|
||||
createLayer,
|
||||
renderLayer: (layer) => _renderLayerWithAdjLayers(layer),
|
||||
lassoToMask: () => _lassoToMask(),
|
||||
wandToMask: () => _wandToMask(),
|
||||
getActiveMaskLayer: () => _getActiveMaskLayer(),
|
||||
@@ -3137,7 +3592,7 @@ function flatten() {
|
||||
if (!layer.visible) continue;
|
||||
ctx.globalAlpha = layer.opacity;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
ctx.drawImage(layer.canvas, off.x, off.y);
|
||||
ctx.drawImage(_renderLayerWithAdjLayers(layer), off.x, off.y);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
return out;
|
||||
@@ -3881,6 +4336,9 @@ export function closeEditor() {
|
||||
el.remove();
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
document.querySelectorAll('body > #ge-save-menu').forEach(el => el.remove());
|
||||
} catch {}
|
||||
// Belt-and-suspenders: scrub any minimized-dock chip + modalManager
|
||||
// entry whose id matches our ephemeral popups (in case the DOM node
|
||||
// was already removed when the user dragged the chip to trash).
|
||||
|
||||
+14
-1
@@ -3,22 +3,35 @@
|
||||
|
||||
import Storage from './storage.js';
|
||||
|
||||
function markComposerUserEdited() {
|
||||
const msgInput = document.getElementById('message');
|
||||
if (!msgInput || msgInput.dataset.startupPreserveBound === '1') return;
|
||||
msgInput.dataset.startupPreserveBound = '1';
|
||||
msgInput.addEventListener('input', () => {
|
||||
window.__odysseusComposerUserEdited = !!msgInput.value;
|
||||
});
|
||||
}
|
||||
|
||||
function clearFreshComposerRestore() {
|
||||
const msgInput = document.getElementById('message');
|
||||
if (!msgInput) return;
|
||||
markComposerUserEdited();
|
||||
const hash = window.location.hash || '';
|
||||
const isEntityHash = /^#(?:document|note|image|email|event|task|skill|research)-/.test(hash)
|
||||
|| /^#open=notes¬e=/.test(hash);
|
||||
const hasSessionTarget = !!((hash && !isEntityHash) || Storage.get('lastSessionId'));
|
||||
const hasSessionTarget = !!(hash && !isEntityHash);
|
||||
if (hasSessionTarget) return;
|
||||
if (window.__odysseusComposerUserEdited || document.activeElement === msgInput) return;
|
||||
if (msgInput.value) {
|
||||
msgInput.value = '';
|
||||
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
markComposerUserEdited();
|
||||
clearFreshComposerRestore();
|
||||
window.addEventListener('pageshow', clearFreshComposerRestore);
|
||||
document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: true });
|
||||
|
||||
// SECURITY: defense-in-depth state wipe on user switch. If the authenticated
|
||||
// user is different from the one whose state is cached in this browser,
|
||||
|
||||
@@ -15,6 +15,7 @@ let activeCategory = 'all';
|
||||
let sortOrder = 'newest';
|
||||
let selectMode = false;
|
||||
let selectedIds = new Set();
|
||||
let memoriesLoading = false;
|
||||
|
||||
|
||||
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
|
||||
@@ -370,12 +371,16 @@ async function syncPrefToggle(elementId, prefKey, onMsg, offMsg, dimBelow = true
|
||||
|
||||
export async function loadMemories() {
|
||||
_ensureNewMemoryCategorySelect();
|
||||
memoriesLoading = true;
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
try {
|
||||
const response = await fetch(`${window.location.origin}/api/memory`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Memory fetch failed with status:', response.status);
|
||||
memories = [];
|
||||
memoriesLoading = false;
|
||||
buildCategoryChips();
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
@@ -393,12 +398,14 @@ export async function loadMemories() {
|
||||
memories = [];
|
||||
}
|
||||
|
||||
memoriesLoading = false;
|
||||
buildCategoryChips();
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
} catch (error) {
|
||||
console.error('Failed to load memories:', error);
|
||||
memories = [];
|
||||
memoriesLoading = false;
|
||||
buildCategoryChips();
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
@@ -689,6 +696,12 @@ export function renderMemoryList() {
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
if (selectBtn) selectBtn.disabled = true;
|
||||
if (selectMode) exitSelectMode();
|
||||
if (memoriesLoading) {
|
||||
const row = spinnerModule.createLoadingRow('Loading memories...', 14);
|
||||
row.classList.add('memory-empty');
|
||||
memoryList.replaceChildren(row);
|
||||
return;
|
||||
}
|
||||
const searchTerm = document.getElementById('memory-search')?.value?.trim() || '';
|
||||
const _smiley = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('smiley') + '</span>';
|
||||
if (searchTerm || activeCategory !== 'all') {
|
||||
@@ -1065,6 +1078,11 @@ export function updateMemoryCount() {
|
||||
const h2Count = document.getElementById('memory-count-h2');
|
||||
const tabCount = document.getElementById('memory-count'); // optional (may be absent)
|
||||
if (!h2Count && !tabCount) return;
|
||||
if (memoriesLoading) {
|
||||
if (h2Count) h2Count.textContent = 'loading...';
|
||||
if (tabCount) tabCount.textContent = '...';
|
||||
return;
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('memory-search');
|
||||
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||
|
||||
@@ -143,7 +143,7 @@ const _LABELS = {
|
||||
'custom-preset-modal': { label: 'Prompt', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg>' },
|
||||
'research-overlay': { label: 'Research', icon: 'M3 11a8 8 0 1 0 16 0a8 8 0 1 0-16 0M21 21l-4.35-4.35M11 8L11 14M8 11L14 11' },
|
||||
'theme-modal': { label: 'Theme', icon: 'M12 2a10 10 0 1 0 10 10c0-1-1-2-2-2h-2a2 2 0 0 1 0-4h1a2 2 0 0 0 0-4 10 10 0 0 0-7-2zM7.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM12 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM16.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z' },
|
||||
'compare-model-overlay': { label: 'Compare', icon: 'M8 3v18M16 3v18M3 8h5M16 16h5' },
|
||||
'compare-model-overlay': { label: 'Compare', icon: 'M4.5 4h5A1.5 1.5 0 0 1 11 5.5v13A1.5 1.5 0 0 1 9.5 20h-5A1.5 1.5 0 0 1 3 18.5v-13A1.5 1.5 0 0 1 4.5 4ZM15.5 4h5A1.5 1.5 0 0 1 22 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-5a1.5 1.5 0 0 1-1.5-1.5v-13A1.5 1.5 0 0 1 15.5 4ZM10 8h4M10 16h4' },
|
||||
'settings-modal': { label: 'Settings', icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.4.4.62.94.6 1.51V11a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z' },
|
||||
'ge-shortcuts-modal':{ label: 'Shortcuts', icon: 'M2 6h20v12H2zM6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10' },
|
||||
// Virtual id — the doc editor pane isn't a modal, but it minimizes to a
|
||||
|
||||
+201
-40
@@ -5,6 +5,7 @@ import { providerLogo } from './providers.js';
|
||||
import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
@@ -51,6 +52,11 @@ function _toggleFavorite(mid) {
|
||||
return i < 0; // true when now favorited
|
||||
}
|
||||
|
||||
function _pickerModelKey(m) {
|
||||
if (!m) return '';
|
||||
return `${m.endpointId || m.url || m.epName || 'model'}::${m.mid || ''}`;
|
||||
}
|
||||
|
||||
// ── Shared keyboard nav for model pickers ──
|
||||
function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
if (e.key === 'Escape') { closeFn(); return; }
|
||||
@@ -78,6 +84,7 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
let _deps = null;
|
||||
let _autoSelectingDefault = false;
|
||||
let _defaultChatPickInFlight = false;
|
||||
let _defaultPendingSeq = 0;
|
||||
|
||||
function _modelExists(modelId, url) {
|
||||
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
|
||||
@@ -121,17 +128,29 @@ async function _ensureDefaultPendingChat() {
|
||||
if (!_deps || _defaultChatPickInFlight) return;
|
||||
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
|
||||
const pending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (pending && pending.modelId && pending.source === 'manual') return;
|
||||
if (pending && pending.modelId) return;
|
||||
_defaultChatPickInFlight = true;
|
||||
const seq = ++_defaultPendingSeq;
|
||||
try {
|
||||
await _ensureModelCacheForFallback();
|
||||
let dc = null;
|
||||
try {
|
||||
dc = window.__odysseusDefaultChat || null;
|
||||
} catch (_) {}
|
||||
if (!dc || !dc.endpoint_url || !dc.model) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
|
||||
if (res.ok) dc = await res.json();
|
||||
} catch (_) {}
|
||||
if (dc && dc.endpoint_url && dc.model && _modelExists(dc.model, dc.endpoint_url)) {
|
||||
const pendingUrl = String((pending && pending.url) || '').replace(/\/+$/, '');
|
||||
}
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
if (seq !== _defaultPendingSeq) return;
|
||||
const latest = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
|
||||
try {
|
||||
window.__odysseusDefaultChat = dc;
|
||||
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
|
||||
} catch (_) {}
|
||||
const pendingUrl = String((latest && latest.url) || '').replace(/\/+$/, '');
|
||||
const defaultUrl = String(dc.endpoint_url || '').replace(/\/+$/, '');
|
||||
_deps.setPendingChat({
|
||||
url: dc.endpoint_url,
|
||||
@@ -139,17 +158,20 @@ async function _ensureDefaultPendingChat() {
|
||||
endpointId: dc.endpoint_id || '',
|
||||
source: 'default',
|
||||
});
|
||||
try { window.__odysseusDefaultChat = dc; } catch (_) {}
|
||||
if (!pending || pending.modelId !== dc.model || pendingUrl !== defaultUrl || pending.source !== 'default') {
|
||||
if (!latest || latest.modelId !== dc.model || pendingUrl !== defaultUrl || latest.source !== 'default') {
|
||||
updateModelPicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pending && pending.modelId) return;
|
||||
await _ensureModelCacheForFallback();
|
||||
// No configured default, or the configured default is gone/offline:
|
||||
// preserve the convenience fallback and keep the picker usable.
|
||||
const fallback = _firstAvailableModel();
|
||||
if (fallback) {
|
||||
if (seq !== _defaultPendingSeq) return;
|
||||
const latest = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
|
||||
_deps.setPendingChat({ ...fallback, source: 'fallback' });
|
||||
updateModelPicker();
|
||||
}
|
||||
@@ -181,6 +203,8 @@ function _initModelPickerDropdown() {
|
||||
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
|
||||
const refreshBtn = document.getElementById('model-picker-refresh-btn');
|
||||
if (!wrap || !btn || !menu || !search || !listEl) return;
|
||||
if (wrap.dataset.modelPickerBound === '1') return;
|
||||
wrap.dataset.modelPickerBound = '1';
|
||||
|
||||
function _close() {
|
||||
if (menu.classList.contains('hidden')) return;
|
||||
@@ -227,10 +251,13 @@ function _initModelPickerDropdown() {
|
||||
|
||||
// Local endpoint health — only probed for LOCAL endpoints, since
|
||||
// cloud APIs are essentially always up. Cached briefly on the
|
||||
// server side too (8s TTL). Picker opens trigger a refresh.
|
||||
// server side too (8s TTL). Picker opens do not probe; the refresh button
|
||||
// is the explicit network/probe action.
|
||||
let _localProbe = {}; // {endpoint_id: {alive, latency_ms, error}}
|
||||
let _localProbeFetchedAt = 0;
|
||||
const _LOCAL_PROBE_TTL_MS = 5000;
|
||||
let _pickerLoading = false;
|
||||
let _pickerLoadSeq = 0;
|
||||
|
||||
async function _refreshLocalProbe() {
|
||||
try {
|
||||
@@ -263,18 +290,26 @@ function _initModelPickerDropdown() {
|
||||
// Mark local endpoints whose live probe failed.
|
||||
const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null;
|
||||
const isLocalDead = !!(probeResult && probeResult.alive === false);
|
||||
const isApiEndpoint = item.category && item.category !== 'local';
|
||||
allModels.forEach((mid, i) => {
|
||||
// Deduplicate by model ID — prefer ONLINE endpoint entries over
|
||||
// offline duplicates so the user gets a working endpoint first
|
||||
// when the same model is exposed by both.
|
||||
if (seen.has(mid)) return;
|
||||
seen.add(mid);
|
||||
// Local/self-hosted servers often expose the same model through several
|
||||
// stale endpoints, so keep deduping those by model id. Cloud/API
|
||||
// endpoints are user-selected provider routes; the same model id can be
|
||||
// intentionally enabled on OpenRouter and OpenAI, so key those by
|
||||
// endpoint too or the chat picker silently drops one.
|
||||
const seenKey = isApiEndpoint
|
||||
? `${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}`
|
||||
: mid;
|
||||
if (seen.has(seenKey)) return;
|
||||
seen.add(seenKey);
|
||||
result.push({
|
||||
key: seenKey,
|
||||
mid,
|
||||
display: (allDisplay[i] || mid).split('/').pop(),
|
||||
url: item.url,
|
||||
endpointId: item.endpoint_id,
|
||||
epName: item.endpoint_name || '',
|
||||
category: item.category || '',
|
||||
providerText: [
|
||||
item.endpoint_name || '',
|
||||
item.category || '',
|
||||
@@ -292,6 +327,48 @@ function _initModelPickerDropdown() {
|
||||
return sortModelObjects(result);
|
||||
}
|
||||
|
||||
function _hasModelCache() {
|
||||
try {
|
||||
return !!(window.modelsModule && window.modelsModule.getCachedItems && (window.modelsModule.getCachedItems() || []).length);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderLoading(text = 'Loading models…') {
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.remove('is-empty');
|
||||
listEl.classList.add('is-loading');
|
||||
menu.classList.remove('no-models');
|
||||
if (search) search.placeholder = text;
|
||||
let row = null;
|
||||
try {
|
||||
row = spinnerModule.createLoadingRow(text, 15);
|
||||
} catch (_) {
|
||||
row = document.createElement('div');
|
||||
row.className = 'model-switch-empty';
|
||||
row.textContent = text;
|
||||
}
|
||||
row.classList.add('model-picker-loading-row');
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
|
||||
async function _refreshPickerModels({ force = false, showLoading = false } = {}) {
|
||||
if (!window.modelsModule || typeof window.modelsModule.refreshModels !== 'function') return;
|
||||
const seq = ++_pickerLoadSeq;
|
||||
_pickerLoading = true;
|
||||
if (showLoading) _renderLoading(force ? 'Refreshing models…' : 'Loading models…');
|
||||
try {
|
||||
await window.modelsModule.refreshModels(force);
|
||||
await _refreshLocalProbe();
|
||||
} finally {
|
||||
if (seq === _pickerLoadSeq) {
|
||||
_pickerLoading = false;
|
||||
listEl.classList.remove('is-loading');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider display names and grouping ──
|
||||
const _PROVIDER_NAMES = {
|
||||
'01-ai': 'Yi', 'abacusai': 'Abacus AI', 'adept': 'Adept',
|
||||
@@ -332,6 +409,16 @@ function _initModelPickerDropdown() {
|
||||
function _providerDisplayName(slug) {
|
||||
return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
|
||||
}
|
||||
function _providerGroupKey(m) {
|
||||
if (m && m.category && m.category !== 'local' && m.epName) {
|
||||
return `~endpoint:${m.epName}`;
|
||||
}
|
||||
return _providerSlug((m && m.mid) || '');
|
||||
}
|
||||
function _providerGroupName(key) {
|
||||
if (String(key || '').startsWith('~endpoint:')) return String(key).slice('~endpoint:'.length);
|
||||
return _providerDisplayName(key);
|
||||
}
|
||||
function _providerSlug(mid) {
|
||||
const slash = mid.indexOf('/');
|
||||
let slug = slash > 0 ? mid.substring(0, slash) : 'other';
|
||||
@@ -342,6 +429,7 @@ function _initModelPickerDropdown() {
|
||||
|
||||
function _populate(filter) {
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.remove('is-loading');
|
||||
const all = _getAllModels();
|
||||
const q = (filter || '').trim().toLowerCase();
|
||||
const hasAnyModel = all.length > 0;
|
||||
@@ -359,7 +447,12 @@ function _initModelPickerDropdown() {
|
||||
// Unique lookup so Recent/Favorites (stored as bare model IDs) can be
|
||||
// resolved back to full model objects; drops anything no longer offered.
|
||||
const byId = new Map();
|
||||
all.forEach(m => { if (!byId.has(m.mid)) byId.set(m.mid, m); });
|
||||
const byKey = new Map();
|
||||
all.forEach(m => {
|
||||
const key = _pickerModelKey(m);
|
||||
if (key && !byKey.has(key)) byKey.set(key, m);
|
||||
if (!byId.has(m.mid)) byId.set(m.mid, m);
|
||||
});
|
||||
|
||||
const favs = _loadFavorites();
|
||||
|
||||
@@ -470,44 +563,44 @@ function _initModelPickerDropdown() {
|
||||
// list fits below as "All models" and a separate Recent
|
||||
// section just duplicates rows.
|
||||
const shown = new Set();
|
||||
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
|
||||
const favModels = favs.map(id => byKey.get(id) || byId.get(id)).filter(Boolean);
|
||||
if (favModels.length) {
|
||||
_addSection('Favorites');
|
||||
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
|
||||
}
|
||||
// Recent: only render when the catalog is big enough that surfacing
|
||||
// a recency shortlist is actually useful, AND only models that
|
||||
// aren't already in Favorites (dedupe).
|
||||
if (all.length > BROWSE_ALL_LIMIT) {
|
||||
const recentModels = _loadRecent()
|
||||
.map(id => byId.get(id))
|
||||
.map(id => byKey.get(id) || byId.get(id))
|
||||
.filter(Boolean)
|
||||
.filter(m => !shown.has(m.mid))
|
||||
.filter(m => !shown.has(_pickerModelKey(m)))
|
||||
.slice(0, RECENT_MAX);
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
|
||||
}
|
||||
}
|
||||
|
||||
// Small catalogs: still list everything so users aren't forced to search.
|
||||
if (all.length <= BROWSE_ALL_LIMIT) {
|
||||
const rest = all.filter(m => !shown.has(m.mid));
|
||||
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
|
||||
if (rest.length) {
|
||||
if (shown.size) _addSection('All models');
|
||||
rest.forEach(_addRow);
|
||||
}
|
||||
} else {
|
||||
// Large catalog: show provider groups with collapsible sections.
|
||||
const rest = all.filter(m => !shown.has(m.mid));
|
||||
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
|
||||
const groups = new Map();
|
||||
rest.forEach(m => {
|
||||
const slug = _providerSlug(m.mid);
|
||||
const slug = _providerGroupKey(m);
|
||||
if (!groups.has(slug)) groups.set(slug, []);
|
||||
groups.get(slug).push(m);
|
||||
});
|
||||
const sorted = [...groups.keys()].sort((a, b) =>
|
||||
_providerDisplayName(a).localeCompare(_providerDisplayName(b)));
|
||||
_providerGroupName(a).localeCompare(_providerGroupName(b)));
|
||||
|
||||
sorted.forEach(provider => {
|
||||
const models = groups.get(provider);
|
||||
@@ -516,7 +609,7 @@ function _initModelPickerDropdown() {
|
||||
header.className = 'mp-provider-header';
|
||||
header.innerHTML =
|
||||
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`
|
||||
+ `<span class="mp-provider-name">${_providerDisplayName(provider)}</span>`
|
||||
+ `<span class="mp-provider-name">${_providerGroupName(provider)}</span>`
|
||||
+ `<span class="mp-provider-count">${models.length}</span>`;
|
||||
header.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -548,13 +641,32 @@ function _initModelPickerDropdown() {
|
||||
}
|
||||
}
|
||||
|
||||
async function _pick(m) {
|
||||
async function _pick(m) {
|
||||
_defaultPendingSeq++;
|
||||
try {
|
||||
window.__odysseusLastPickedRoute = {
|
||||
model: m.mid || '',
|
||||
endpoint_url: m.url || '',
|
||||
endpoint_id: m.endpointId || '',
|
||||
display: m.display || m.mid || '',
|
||||
picked_at: Date.now(),
|
||||
};
|
||||
} catch (_) {}
|
||||
let switchDone = null;
|
||||
const switchPromise = new Promise(resolve => { switchDone = resolve; });
|
||||
try { window.__odysseusModelSwitchPromise = switchPromise; } catch (_) {}
|
||||
const finishSwitch = () => {
|
||||
try {
|
||||
if (switchDone) switchDone();
|
||||
if (window.__odysseusModelSwitchPromise === switchPromise) delete window.__odysseusModelSwitchPromise;
|
||||
} catch (_) {}
|
||||
};
|
||||
const currentSessionId = _deps.getCurrentSessionId();
|
||||
const _pendingChat = _deps.getPendingChat();
|
||||
|
||||
// Remember this pick so it surfaces under "Recent" next time the picker
|
||||
// opens — the whole point of quick-switch.
|
||||
if (m && m.mid) _pushRecent(m.mid);
|
||||
if (m && m.mid) _pushRecent(_pickerModelKey(m) || m.mid);
|
||||
|
||||
// Broadcast immediately so listeners (e.g. the tour) can advance without
|
||||
// waiting for the async session-create/PATCH that follows.
|
||||
@@ -574,12 +686,23 @@ function _initModelPickerDropdown() {
|
||||
// Header stays as session name — model switch only updates picker
|
||||
updateModelPicker();
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
finishSwitch();
|
||||
return;
|
||||
} else if (!currentSessionId) {
|
||||
// No session yet — create one with this model
|
||||
try {
|
||||
await _deps.createDirectChat(m.url, m.mid, m.endpointId);
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to start chat: ' + e);
|
||||
finishSwitch();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Existing session with no model — PATCH it
|
||||
const sessions = _deps.getSessions();
|
||||
const s = sessions.find(x => x.id === currentSessionId);
|
||||
if (s) { s.model = m.mid; s.endpoint_url = m.url; s.endpoint_id = m.endpointId || s.endpoint_id || ''; }
|
||||
updateModelPicker();
|
||||
const fd = new FormData();
|
||||
fd.append('model', m.mid);
|
||||
fd.append('endpoint_url', m.url);
|
||||
@@ -588,20 +711,21 @@ function _initModelPickerDropdown() {
|
||||
const res = await fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd });
|
||||
if (!res.ok) {
|
||||
uiModule.showError('Failed to set model');
|
||||
finishSwitch();
|
||||
return;
|
||||
}
|
||||
const sessions = _deps.getSessions();
|
||||
const s = sessions.find(x => x.id === currentSessionId);
|
||||
if (s) { s.model = m.mid; s.endpoint_url = m.url; }
|
||||
// Header stays as session name — model info shown in picker only
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to set model: ' + e);
|
||||
finishSwitch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Update picker visibility — model is now set
|
||||
updateModelPicker();
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('model-pick');
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
finishSwitch();
|
||||
}
|
||||
|
||||
document.addEventListener('odysseus:auto-select-model', async (e) => {
|
||||
@@ -650,14 +774,27 @@ function _initModelPickerDropdown() {
|
||||
if (match) await _pick(match);
|
||||
});
|
||||
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.classList.contains('hidden') || menu.classList.contains('closing')) {
|
||||
// Force-clear any in-progress close animation
|
||||
menu.classList.remove('closing', 'hidden');
|
||||
const hasCache = _hasModelCache();
|
||||
if (hasCache) {
|
||||
_populate('');
|
||||
} else {
|
||||
_renderLoading('Loading models…');
|
||||
}
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels().then(() => {
|
||||
// Force the cheap /api/models cache refresh when the picker opens.
|
||||
// This does not wait on provider probes; the backend returns cached
|
||||
// inventory and starts refresh work separately. Without this, models
|
||||
// enabled in Added Models can be absent from the chatbox picker until
|
||||
// the tab's frontend cache ages out.
|
||||
_refreshPickerModels({ force: hasCache, showLoading: !hasCache }).then(() => {
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
@@ -671,7 +808,10 @@ function _initModelPickerDropdown() {
|
||||
}
|
||||
});
|
||||
|
||||
search.addEventListener('input', () => _populate(search.value));
|
||||
search.addEventListener('input', () => {
|
||||
if (_pickerLoading) return;
|
||||
_populate(search.value);
|
||||
});
|
||||
search.addEventListener('click', (e) => e.stopPropagation());
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', async (e) => {
|
||||
@@ -679,10 +819,7 @@ function _initModelPickerDropdown() {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.classList.add('spinning');
|
||||
try {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
await window.modelsModule.refreshModels(true);
|
||||
}
|
||||
await _refreshLocalProbe();
|
||||
await _refreshPickerModels({ force: true, showLoading: true });
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
} catch (_) {
|
||||
@@ -704,7 +841,7 @@ function _initModelPickerDropdown() {
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
|
||||
if (!menu.classList.contains('hidden') && !wrap.contains(e.target)) {
|
||||
_close();
|
||||
}
|
||||
});
|
||||
@@ -738,16 +875,33 @@ export function updateModelPicker() {
|
||||
let modelId = null;
|
||||
if (s && s.model) {
|
||||
modelId = s.model;
|
||||
if (!_modelExists(modelId, s.endpoint_url || '')) {
|
||||
modelId = null;
|
||||
}
|
||||
} else if (_pendingChat && _pendingChat.modelId) {
|
||||
modelId = _pendingChat.modelId;
|
||||
if (!_modelExists(modelId, _pendingChat.url || '')) {
|
||||
if (_pendingChat.source === 'fallback' && !_modelExists(modelId, _pendingChat.url || '')) {
|
||||
_deps.setPendingChat(null);
|
||||
modelId = null;
|
||||
}
|
||||
}
|
||||
if (!modelId && !currentSessionId && !_pendingChat && _deps.setPendingChat) {
|
||||
let cachedDefault = null;
|
||||
try {
|
||||
cachedDefault = window.__odysseusDefaultChat || null;
|
||||
} catch (_) {}
|
||||
if (!cachedDefault || !cachedDefault.endpoint_url || !cachedDefault.model) {
|
||||
try {
|
||||
cachedDefault = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
|
||||
} catch (_) {}
|
||||
}
|
||||
if (cachedDefault && cachedDefault.endpoint_url && cachedDefault.model) {
|
||||
modelId = cachedDefault.model;
|
||||
_deps.setPendingChat({
|
||||
url: cachedDefault.endpoint_url,
|
||||
modelId,
|
||||
endpointId: cachedDefault.endpoint_id || '',
|
||||
source: 'default',
|
||||
});
|
||||
}
|
||||
}
|
||||
// SECURITY: deliberately NOT auto-injecting `odysseus-model-favorites[0]`
|
||||
// here. localStorage favorites are per-browser, not per-user, so on a
|
||||
// shared browser the previous account's first favorited model would
|
||||
@@ -757,7 +911,14 @@ export function updateModelPicker() {
|
||||
//
|
||||
// Check if selected model is still available — fall back ONLY for pending chats with no user selection
|
||||
// Never override an existing session's model — the user explicitly chose it
|
||||
if (modelId && !currentSessionId && _pendingChat && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
if (
|
||||
modelId &&
|
||||
!currentSessionId &&
|
||||
_pendingChat &&
|
||||
_pendingChat.source !== 'manual' &&
|
||||
window.modelsModule &&
|
||||
window.modelsModule.getCachedItems
|
||||
) {
|
||||
const items = window.modelsModule.getCachedItems();
|
||||
const allAvailable = [];
|
||||
items.forEach(item => {
|
||||
|
||||
+14
-30
@@ -164,21 +164,31 @@ function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function refreshModels(force = false) {
|
||||
export async function refreshModels(force = false, opts = {}) {
|
||||
const box = document.getElementById('models');
|
||||
const cacheOnly = !!(opts && opts.cacheOnly);
|
||||
const hasCache = _cachedItems.length > 0;
|
||||
|
||||
// Skip network fetch if cache is fresh and not forced — still re-render UI
|
||||
// Cache-only is used for cheap picker/settings opens, but it must not turn a
|
||||
// cold page load into an empty model list. If nothing has been fetched in this
|
||||
// tab yet, do one normal load.
|
||||
const now = Date.now();
|
||||
const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
|
||||
const needsFetch = !(cacheOnly && hasCache) && (force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL);
|
||||
|
||||
if (box) box.innerHTML = '';
|
||||
const hadRenderedRows = !!(box && box.children && box.children.length);
|
||||
if (box && (!needsFetch || !hadRenderedRows)) box.innerHTML = '';
|
||||
if (needsFetch) {
|
||||
let _loadingSpinner = null;
|
||||
if (box) {
|
||||
if (hadRenderedRows) {
|
||||
box.classList.add('models-refreshing');
|
||||
} else {
|
||||
_loadingSpinner = spinnerModule.create('', 'right', 'wave');
|
||||
box.appendChild(_loadingSpinner.createElement());
|
||||
_loadingSpinner.start();
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (force) _fetchInflight = null;
|
||||
if (!_fetchInflight) {
|
||||
@@ -208,6 +218,7 @@ export async function refreshModels(force = false) {
|
||||
return;
|
||||
} finally {
|
||||
try { _loadingSpinner && _loadingSpinner.stop && _loadingSpinner.stop(); } catch (_) {}
|
||||
if (box) box.classList.remove('models-refreshing');
|
||||
if (box) box.innerHTML = '';
|
||||
}
|
||||
}
|
||||
@@ -576,33 +587,6 @@ export async function refreshModels(force = false) {
|
||||
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
|
||||
}
|
||||
box.appendChild(noModels);
|
||||
// No endpoints yet: keep the welcome screen focused on first setup.
|
||||
const welcomeSub = document.getElementById('welcome-sub');
|
||||
if (welcomeSub) welcomeSub.innerHTML = 'Type <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">/setup</span> to get started.';
|
||||
const welcomeTip = document.getElementById('welcome-tip');
|
||||
if (welcomeTip) welcomeTip.textContent = 'Type /setup, then choose Local models or API.';
|
||||
} else {
|
||||
// Configured installs should feel ready, not stuck in onboarding.
|
||||
const welcomeSub = document.getElementById('welcome-sub');
|
||||
if (welcomeSub) welcomeSub.textContent = 'Yours for the voyage.';
|
||||
const welcomeTip = document.getElementById('welcome-tip');
|
||||
if (welcomeTip) {
|
||||
const tips = window.innerWidth <= 768
|
||||
? [
|
||||
'Tip: Long-press a session for rename, delete, and memory options.',
|
||||
'Tip: Tap the eye icon for Nobody mode - no history saved.',
|
||||
'Tip: Switch to Agent mode when you want tools.',
|
||||
'Tip: Attach images or files using the + button next to the input.',
|
||||
]
|
||||
: [
|
||||
'Tip: Press Ctrl+K to search across all your conversations.',
|
||||
'Tip: Press Ctrl+B to quickly toggle the sidebar.',
|
||||
'Tip: Shift-click the sidebar toggle to swap it to the other side.',
|
||||
'Tip: Drag and drop files onto the chat to attach them.',
|
||||
'Tip: Right-click a session for rename, delete, and memory options.',
|
||||
];
|
||||
welcomeTip.textContent = tips[Math.floor(Math.random() * tips.length)];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
+34
-42
@@ -392,6 +392,7 @@ let _loading = false;
|
||||
// Undo stack — most recent action is at the end. We cap it small because the
|
||||
// only entries that survive a panel reload are in-memory anyway.
|
||||
const _undoStack = [];
|
||||
const _NOTE_UNDO_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
|
||||
function _pushUndo(entry) {
|
||||
_undoStack.push(entry);
|
||||
if (_undoStack.length > 20) _undoStack.shift();
|
||||
@@ -416,6 +417,33 @@ function _undoArchive(note, prevIdx) {
|
||||
});
|
||||
}
|
||||
|
||||
function _archiveNoteById(id, { card = null, celebrate = false } = {}) {
|
||||
if (!id) return false;
|
||||
const idx = _notes.findIndex(n => n.id === id);
|
||||
if (idx < 0) return false;
|
||||
const note = _notes[idx];
|
||||
if (celebrate && card && note && _hasItems(note)) {
|
||||
const undone = (note.items || []).filter(i => !i.done);
|
||||
if (undone.length === 0) {
|
||||
const r = card.getBoundingClientRect();
|
||||
spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
|
||||
}
|
||||
}
|
||||
const removed = _notes.splice(idx, 1)[0];
|
||||
_editingId = null;
|
||||
_renderNotes();
|
||||
const undo = () => _undoArchive(removed, idx);
|
||||
_pushUndo({ label: 'archive', run: undo });
|
||||
_patchNote(id, { archived: true }).then(() => {
|
||||
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' });
|
||||
}).catch(() => {
|
||||
_notes.splice(idx, 0, removed);
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to archive');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _fetchNotes() {
|
||||
_loading = true;
|
||||
try {
|
||||
@@ -2396,34 +2424,12 @@ function _bindCardEvents(body) {
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.noteId;
|
||||
if (!id) return;
|
||||
const note = _notes.find(n => n.id === id);
|
||||
const card = btn.closest('.note-card');
|
||||
// Confetti when archiving a fully-completed checklist (todo or goal).
|
||||
if (note && _hasItems(note) && card) {
|
||||
const undone = (note.items || []).filter(i => !i.done);
|
||||
if (undone.length === 0) {
|
||||
const r = card.getBoundingClientRect();
|
||||
spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
|
||||
}
|
||||
}
|
||||
let done = false;
|
||||
const finishRemove = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const curIdx = _notes.findIndex(n => n.id === id);
|
||||
if (curIdx < 0) return;
|
||||
const removed = _notes.splice(curIdx, 1)[0];
|
||||
_renderNotes();
|
||||
const undo = () => _undoArchive(removed, curIdx);
|
||||
_pushUndo({ label: 'archive', run: undo });
|
||||
const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
|
||||
_patchNote(id, { archived: true }).then(() => {
|
||||
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
|
||||
}).catch(() => {
|
||||
_notes.splice(curIdx, 0, removed);
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to archive');
|
||||
});
|
||||
_archiveNoteById(id, { card, celebrate: true });
|
||||
};
|
||||
if (card) {
|
||||
card.classList.add('note-card-sliding-out');
|
||||
@@ -3615,10 +3621,11 @@ function _buildForm(note = null) {
|
||||
if (_saveBtn._saving) return;
|
||||
// Mobile: when an existing note is opened and closed without edits, the
|
||||
// Update (✓) button morphs into Archive (set up below). Route the click
|
||||
// to the hidden archive button so the existing archive flow + undo toast
|
||||
// run unchanged.
|
||||
// directly through the archive flow. Using a DOM .click() proxy here was
|
||||
// fragile on mobile because the real archive button can move from the
|
||||
// footer to the fullscreen header.
|
||||
if (_saveBtn.classList.contains('archive-mode')) {
|
||||
form.querySelector('.note-form-archive-btn')?.click();
|
||||
_archiveNoteById(note?.id);
|
||||
return;
|
||||
}
|
||||
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
|
||||
@@ -3743,22 +3750,7 @@ function _buildForm(note = null) {
|
||||
// Archive / Delete — edit-mode-only buttons, mirror the (now-hidden) card actions.
|
||||
form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => {
|
||||
if (!isEdit) return;
|
||||
const id = note.id;
|
||||
const idx = _notes.findIndex(n => n.id === id);
|
||||
if (idx < 0) return;
|
||||
const removed = _notes.splice(idx, 1)[0];
|
||||
_editingId = null;
|
||||
_renderNotes();
|
||||
const undo = () => _undoArchive(removed, idx);
|
||||
_pushUndo({ label: 'archive', run: undo });
|
||||
const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
|
||||
_patchNote(id, { archived: true }).then(() => {
|
||||
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
|
||||
}).catch(() => {
|
||||
_notes.splice(idx, 0, removed);
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to archive');
|
||||
});
|
||||
_archiveNoteById(note.id);
|
||||
});
|
||||
form.querySelector('.note-form-delete-btn')?.addEventListener('click', async () => {
|
||||
if (!isEdit) return;
|
||||
|
||||
@@ -10,7 +10,29 @@ let results = [];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function hideMobileSidebarForSearch() {
|
||||
if (window.innerWidth >= 768) return;
|
||||
const sidebar = el('sidebar');
|
||||
const rail = el('icon-rail');
|
||||
const backdrop = el('sidebar-backdrop');
|
||||
let changed = false;
|
||||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||||
sidebar.classList.add('hidden');
|
||||
changed = true;
|
||||
}
|
||||
if (rail && rail.classList.contains('mobile-mini')) {
|
||||
rail.classList.remove('mobile-mini');
|
||||
rail.style.cssText = '';
|
||||
changed = true;
|
||||
}
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
if (changed && typeof window.syncRailSide === 'function') {
|
||||
try { window.syncRailSide(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
export function openSearch() {
|
||||
hideMobileSidebarForSearch();
|
||||
const overlay = el('search-overlay');
|
||||
if (!overlay) return;
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
+235
-53
@@ -3,9 +3,9 @@
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule, { autoResize, styledPrompt } from './ui.js';
|
||||
import chatRenderer from './chatRenderer.js';
|
||||
import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { initModelPicker, updateModelPicker } from './modelPicker.js';
|
||||
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
|
||||
import themeModule from './theme.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
@@ -16,6 +16,7 @@ let currentSessionId = null;
|
||||
let _sessionNavToken = 0;
|
||||
let _skipAutoSelect = false;
|
||||
let _suppressNextSessionLoading = false;
|
||||
let _rootFreshChatApplied = false;
|
||||
const HISTORY_DISPLAY_CHAR_LIMIT = 160000;
|
||||
const HISTORY_DISPLAY_TAIL_CHARS = 20000;
|
||||
const HISTORY_PAGE_LIMIT_MOBILE = 8;
|
||||
@@ -26,12 +27,29 @@ const FOLDER_MAX_VISIBLE = 5;
|
||||
let _showAllSessions = false;
|
||||
let _expandedFolders = {}; // folderName -> true if "show more" clicked
|
||||
let _sortMode = Storage.get('odysseus-session-sort') || 'active'; // default to last active
|
||||
const DATE_SECTION_COLLAPSE_KEY = 'ody-session-date-section-collapsed';
|
||||
let _autoCreateInProgress = false; // guard against recursive auto-create
|
||||
const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key for incognito session IDs
|
||||
const _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const _mod = _isMac ? '⌘' : 'Ctrl';
|
||||
let _historyPager = null;
|
||||
|
||||
function _shouldPreserveStartupComposer(msgInput) {
|
||||
if (!msgInput || !msgInput.value) return false;
|
||||
if (window.__odysseusComposerUserEdited) return true;
|
||||
return !!document.getElementById('app-loader') && document.activeElement === msgInput;
|
||||
}
|
||||
|
||||
function _clearComposerUnlessStartupTyped(msgInput) {
|
||||
if (!msgInput) return;
|
||||
if (_shouldPreserveStartupComposer(msgInput)) {
|
||||
msgInput.disabled = false;
|
||||
autoResize(msgInput);
|
||||
return;
|
||||
}
|
||||
msgInput.value = '';
|
||||
}
|
||||
|
||||
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
|
||||
if (!chatHistory) return;
|
||||
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
|
||||
@@ -973,21 +991,68 @@ function _sessionBucketDate(s) {
|
||||
return s.last_message_at || s.updated_at || s.created_at || '';
|
||||
}
|
||||
|
||||
function _loadDateSectionCollapseState() {
|
||||
const raw = Storage.getJSON(DATE_SECTION_COLLAPSE_KEY, {});
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
return raw;
|
||||
}
|
||||
|
||||
function _saveDateSectionCollapseState(state) {
|
||||
Storage.setJSON(DATE_SECTION_COLLAPSE_KEY, state && typeof state === 'object' ? state : {});
|
||||
}
|
||||
|
||||
function _dateSectionKey(kind, label) {
|
||||
return `${kind || 'session'}:${label || 'Older'}`;
|
||||
}
|
||||
|
||||
function _isDateSectionCollapsed(kind, label) {
|
||||
return _loadDateSectionCollapseState()[_dateSectionKey(kind, label)] === true;
|
||||
}
|
||||
|
||||
function _toggleDateSection(kind, label) {
|
||||
const state = _loadDateSectionCollapseState();
|
||||
const key = _dateSectionKey(kind, label);
|
||||
state[key] = state[key] !== true;
|
||||
_saveDateSectionCollapseState(state);
|
||||
renderSessionList();
|
||||
}
|
||||
|
||||
function _createDateSectionHeader(label, kind = 'session') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `date-section-header ${kind}-date-section-header`;
|
||||
el.textContent = label;
|
||||
const collapsed = _isDateSectionCollapsed(kind, label);
|
||||
if (collapsed) el.classList.add('collapsed');
|
||||
el.dataset.dateSectionKind = kind;
|
||||
el.dataset.dateSectionLabel = label;
|
||||
el.tabIndex = 0;
|
||||
el.setAttribute('role', 'button');
|
||||
el.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
el.title = collapsed ? `Show ${label}` : `Hide ${label}`;
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_toggleDateSection(kind, label);
|
||||
});
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_toggleDateSection(kind, label);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function _appendSessionItemsWithDateHeaders(frag, items) {
|
||||
let lastLabel = null;
|
||||
let collapsed = false;
|
||||
for (const s of items) {
|
||||
const label = _dateBucketLabel(_sessionBucketDate(s));
|
||||
if (label !== lastLabel) {
|
||||
frag.appendChild(_createDateSectionHeader(label, 'session'));
|
||||
collapsed = _isDateSectionCollapsed('session', label);
|
||||
lastLabel = label;
|
||||
}
|
||||
if (collapsed) continue;
|
||||
frag.appendChild(createSessionItem(s));
|
||||
}
|
||||
}
|
||||
@@ -995,6 +1060,7 @@ function _appendSessionItemsWithDateHeaders(frag, items) {
|
||||
function _appendFavoriteSessionItems(frag, items) {
|
||||
if (!items.length) return;
|
||||
frag.appendChild(_createDateSectionHeader('Favorites', 'session'));
|
||||
if (_isDateSectionCollapsed('session', 'Favorites')) return;
|
||||
for (const s of items) {
|
||||
frag.appendChild(createSessionItem(s));
|
||||
}
|
||||
@@ -1226,9 +1292,7 @@ function _renderSessionListImpl() {
|
||||
visibleFolder.push(folderSessions[activeInFolder]);
|
||||
}
|
||||
|
||||
visibleFolder.forEach(s => {
|
||||
content.appendChild(createSessionItem(s));
|
||||
});
|
||||
_appendSessionItemsWithDateHeaders(content, visibleFolder);
|
||||
|
||||
if (folderSessions.length > FOLDER_MAX_VISIBLE) {
|
||||
const rem = folderSessions.length - FOLDER_MAX_VISIBLE;
|
||||
@@ -1328,9 +1392,7 @@ function _renderSessionListImpl() {
|
||||
}
|
||||
|
||||
if (unfiledTarget) {
|
||||
visibleUnfiled.forEach(s => {
|
||||
unfiledTarget.appendChild(createSessionItem(s));
|
||||
});
|
||||
_appendSessionItemsWithDateHeaders(unfiledTarget, visibleUnfiled);
|
||||
}
|
||||
|
||||
// "Show more" / "Show less" toggle
|
||||
@@ -1616,7 +1678,11 @@ export async function loadSessions() {
|
||||
sessionStorage.removeItem('ody-prefetch-sessions');
|
||||
fetched = JSON.parse(prefetched);
|
||||
} else {
|
||||
const res = await fetch(`${API_BASE}/api/sessions`);
|
||||
let url = `${API_BASE}/api/sessions`;
|
||||
if (currentSessionId && _isIncognitoSession(currentSessionId)) {
|
||||
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
|
||||
}
|
||||
const res = await fetch(url);
|
||||
fetched = await res.json();
|
||||
}
|
||||
sessions = _normalizeSessionsList(fetched);
|
||||
@@ -1640,7 +1706,13 @@ export async function loadSessions() {
|
||||
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes¬e=/.test(hashId)) {
|
||||
hashId = '';
|
||||
}
|
||||
let savedId = Storage.get('lastSessionId');
|
||||
const _isFirstLoad = !sessionStorage.getItem('ody-session-active');
|
||||
const _freshRootLoad = !_rootFreshChatApplied && !hashId && !currentSessionId && !_pendingChat;
|
||||
if (_freshRootLoad) {
|
||||
_rootFreshChatApplied = true;
|
||||
Storage.remove('lastSessionId');
|
||||
}
|
||||
let savedId = _freshRootLoad ? null : Storage.get('lastSessionId');
|
||||
// If the persisted lastSessionId points to a transient session (legacy
|
||||
// state from before the persistence-guard was added), drop it.
|
||||
if (savedId) {
|
||||
@@ -1665,13 +1737,13 @@ export async function loadSessions() {
|
||||
} else if (currentSessionId) {
|
||||
// Session was just created but may not be in the list yet — keep it
|
||||
targetId = currentSessionId;
|
||||
} else if (savedId && activeSessions.some(s => s.id === savedId)) {
|
||||
} else if (!_freshRootLoad && savedId && activeSessions.some(s => s.id === savedId)) {
|
||||
targetId = savedId;
|
||||
} else if (!_skipAutoSelect && _realSessions.length > 0) {
|
||||
} else if (!_freshRootLoad && !_skipAutoSelect && _realSessions.length > 0) {
|
||||
// Most-recent NON-transient session — skip Assistant / Tasks so the
|
||||
// auto-firing assistant doesn't become the apparent default chat.
|
||||
targetId = _realSessions[0].id;
|
||||
} else if (!_skipAutoSelect && activeSessions.length > 0) {
|
||||
} else if (!_freshRootLoad && !_skipAutoSelect && activeSessions.length > 0) {
|
||||
// Only transient sessions exist (brand-new account) — fall through to
|
||||
// the original behaviour so we don't leave the user with nothing.
|
||||
targetId = activeSessions[0].id;
|
||||
@@ -1687,32 +1759,16 @@ export async function loadSessions() {
|
||||
// picker would still show the old model's name from cached state). See
|
||||
// the targetId resolution above (hash → currentSession → lastSessionId →
|
||||
// most-recent).
|
||||
const _isFirstLoad = !sessionStorage.getItem('ody-session-active');
|
||||
if (_isFirstLoad) {
|
||||
sessionStorage.setItem('ody-session-active', '1');
|
||||
if (!targetId) {
|
||||
try {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
const dc = await dcRes.json();
|
||||
if (dc.endpoint_url && dc.model) {
|
||||
// Check if there's already an empty session with this model we can reuse
|
||||
const emptyDefault = activeSessions.find(s =>
|
||||
s.model === dc.model && s.message_count === 0
|
||||
);
|
||||
if (emptyDefault) {
|
||||
targetId = emptyDefault.id;
|
||||
} else {
|
||||
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
// On mobile, hide sidebar so user lands directly in chat
|
||||
if (_isFirstLoad) sessionStorage.setItem('ody-session-active', '1');
|
||||
if ((_isFirstLoad || _freshRootLoad) && !targetId) {
|
||||
// Land on a visually fresh chat without creating hidden pending session
|
||||
// state. The send path can create the default-backed session when the
|
||||
// user actually submits. Pre-creating here races with opening an existing
|
||||
// chat and was causing sends to jump into brand-new chats.
|
||||
if (window.innerWidth < 768) {
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (sb) sb.classList.add('hidden');
|
||||
}
|
||||
return; // createDirectChat handles selectSession internally
|
||||
}
|
||||
}
|
||||
} catch (_) { /* no default model configured */ }
|
||||
}
|
||||
}
|
||||
|
||||
const suppressSessionLoading = _suppressNextSessionLoading;
|
||||
@@ -1743,10 +1799,9 @@ export async function loadSessions() {
|
||||
if (activeSessions.length === 0 && !_autoCreateInProgress) {
|
||||
_autoCreateInProgress = true;
|
||||
try {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
const dc = await dcRes.json();
|
||||
if (dc.endpoint_url && dc.model) {
|
||||
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
const dc = await _getPreferredDefaultChat();
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' });
|
||||
}
|
||||
} catch (_) { /* no default model — that's fine, user can /setup */ }
|
||||
_autoCreateInProgress = false;
|
||||
@@ -1767,6 +1822,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
try {
|
||||
const navToken = ++_sessionNavToken;
|
||||
const prevSessionId = currentSessionId;
|
||||
// Selecting a real persisted chat cancels any deferred "New Chat" model
|
||||
// pick. Otherwise the next send can materialize that pending chat instead
|
||||
// of posting into the session the user just opened.
|
||||
if (_pendingChat) {
|
||||
_pendingChat = null;
|
||||
_pendingMaterializePromise = null;
|
||||
}
|
||||
_clearHistoryPager();
|
||||
// Re-archive peeked session when navigating away
|
||||
_checkPeekCleanup(id);
|
||||
@@ -1775,6 +1837,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
try { window.documentModule.clearSelection(); } catch {}
|
||||
}
|
||||
currentSessionId = id;
|
||||
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
|
||||
// Identify Assistant / task-output sessions so we don't "trap" the user
|
||||
// there on return. Skipped from both `lastSessionId` persistence and the
|
||||
// URL hash — the user complained that coming back to Odysseus kept
|
||||
@@ -1784,10 +1847,6 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
|
||||
if (!_isTransientChat) {
|
||||
Storage.set('lastSessionId', id);
|
||||
// Update URL hash without triggering hashchange handler
|
||||
if (window.location.hash !== '#' + id) {
|
||||
history.replaceState(null, '', '#' + id);
|
||||
}
|
||||
}
|
||||
// Restore character preset for persistent chats
|
||||
try {
|
||||
@@ -1827,7 +1886,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
const msgInput = document.getElementById('message');
|
||||
if (msgInput) {
|
||||
msgInput.disabled = false;
|
||||
msgInput.value = '';
|
||||
_clearComposerUnlessStartupTyped(msgInput);
|
||||
msgInput.style.height = '';
|
||||
msgInput.style.overflow = '';
|
||||
autoResize(msgInput);
|
||||
@@ -1859,6 +1918,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
}
|
||||
// Update model picker visibility
|
||||
updateModelPicker();
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('select-session');
|
||||
|
||||
// Refresh session cost badge for the newly selected session
|
||||
if (chatRenderer.updateSessionCostUI) chatRenderer.updateSessionCostUI();
|
||||
@@ -2086,8 +2146,44 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
|
||||
// Pending session — stored locally until the first message is sent
|
||||
let _pendingChat = null; // { url, modelId, endpointId }
|
||||
let _pendingMaterializePromise = null;
|
||||
|
||||
export function createDirectChat(url, modelId, endpointId) {
|
||||
async function _getPreferredDefaultChat() {
|
||||
let dc = null;
|
||||
try {
|
||||
dc = window.__odysseusDefaultChat || null;
|
||||
} catch (_) {}
|
||||
if (!dc || !dc.endpoint_url || !dc.model) {
|
||||
try {
|
||||
dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
|
||||
} catch (_) {}
|
||||
}
|
||||
if (dc && dc.endpoint_url && dc.model) return dc;
|
||||
try {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
dc = await dcRes.json();
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
try {
|
||||
window.__odysseusDefaultChat = dc;
|
||||
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
|
||||
} catch (_) {}
|
||||
return dc;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createDirectChat(url, modelId, endpointId, opts = {}) {
|
||||
const incomingSource = opts.source || 'manual';
|
||||
if (
|
||||
_pendingChat &&
|
||||
_pendingChat.modelId &&
|
||||
_pendingChat.source === 'manual' &&
|
||||
incomingSource !== 'manual'
|
||||
) {
|
||||
updateModelPicker();
|
||||
return;
|
||||
}
|
||||
_sessionNavToken++;
|
||||
// Detach any active stream so it doesn't interfere with the new chat
|
||||
if (window.chatModule && window.chatModule.detachCurrentStream) {
|
||||
@@ -2101,10 +2197,12 @@ export function createDirectChat(url, modelId, endpointId) {
|
||||
}
|
||||
|
||||
// Don't hit the API — just store the model info and prepare the UI
|
||||
_pendingChat = { url, modelId, endpointId };
|
||||
_pendingChat = { url, modelId, endpointId, source: incomingSource };
|
||||
_pendingMaterializePromise = null;
|
||||
_skipAutoSelect = true;
|
||||
_suppressNextSessionLoading = true;
|
||||
currentSessionId = null;
|
||||
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
|
||||
@@ -2132,6 +2230,7 @@ export function createDirectChat(url, modelId, endpointId) {
|
||||
|
||||
// Update model picker to show the pending model
|
||||
updateModelPicker();
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('new-chat');
|
||||
|
||||
// Update current-meta header
|
||||
const metaEl = document.getElementById('current-meta');
|
||||
@@ -2141,14 +2240,20 @@ export function createDirectChat(url, modelId, endpointId) {
|
||||
|
||||
// Enable input
|
||||
const msgInput = document.getElementById('message');
|
||||
if (msgInput) { msgInput.disabled = false; msgInput.value = ''; msgInput.focus(); }
|
||||
if (msgInput) {
|
||||
msgInput.disabled = false;
|
||||
_clearComposerUnlessStartupTyped(msgInput);
|
||||
msgInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Actually create the session in the DB. Called on first message send. */
|
||||
export async function materializePendingSession() {
|
||||
if (_pendingMaterializePromise) return _pendingMaterializePromise;
|
||||
const pending = _pendingChat;
|
||||
if (!pending) return false;
|
||||
_pendingChat = null;
|
||||
|
||||
const materializePromise = (async () => {
|
||||
|
||||
const incognitoChk = document.getElementById('incognito-toggle');
|
||||
const isIncognito = incognitoChk && incognitoChk.checked;
|
||||
@@ -2186,6 +2291,16 @@ export async function materializePendingSession() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The user may have opened an existing chat while this deferred default
|
||||
// session was being created. Do not let a stale response steal
|
||||
// currentSessionId and make the next send land in a brand-new chat.
|
||||
if (_pendingChat !== pending) {
|
||||
if (payload.id) {
|
||||
fetch(`${API_BASE}/api/session/${encodeURIComponent(payload.id)}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIncognito && payload.id) {
|
||||
_markIncognito(payload.id);
|
||||
}
|
||||
@@ -2194,16 +2309,42 @@ export async function materializePendingSession() {
|
||||
if (window.documentModule?.clearSelection) {
|
||||
try { window.documentModule.clearSelection(); } catch {}
|
||||
}
|
||||
_pendingChat = null;
|
||||
currentSessionId = payload.id;
|
||||
if (!isIncognito) {
|
||||
Storage.set('lastSessionId', payload.id);
|
||||
history.replaceState(null, '', '#' + payload.id);
|
||||
}
|
||||
|
||||
// Reload the sidebar in the background. Awaiting this used to block the first
|
||||
// prompt in a new/pending chat behind startup fetches and slow /api/sessions
|
||||
// calls, so the user's message could sit for 20s+ before streaming began.
|
||||
_suppressNextSessionLoading = true;
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('materialize-session');
|
||||
loadSessions().catch(() => {});
|
||||
return true;
|
||||
})();
|
||||
_pendingMaterializePromise = materializePromise;
|
||||
|
||||
try {
|
||||
return await materializePromise;
|
||||
} finally {
|
||||
if (_pendingMaterializePromise === materializePromise) {
|
||||
_pendingMaterializePromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function preMaterializePendingSession() {
|
||||
if (!_pendingChat || _pendingMaterializePromise) return;
|
||||
const incognitoChk = document.getElementById('incognito-toggle');
|
||||
if (incognitoChk && incognitoChk.checked) return;
|
||||
setTimeout(() => {
|
||||
const chk = document.getElementById('incognito-toggle');
|
||||
if (chk && chk.checked) return;
|
||||
if (_pendingChat && !_pendingMaterializePromise) {
|
||||
materializePendingSession().catch(() => {});
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
export function hasPendingChat() { return !!_pendingChat; }
|
||||
@@ -2213,6 +2354,10 @@ export function getCurrentSessionId() {
|
||||
return currentSessionId;
|
||||
}
|
||||
|
||||
export function isCurrentSessionIncognito() {
|
||||
return !!(currentSessionId && _isIncognitoSession(currentSessionId));
|
||||
}
|
||||
|
||||
export function getSessions() {
|
||||
return sessions;
|
||||
}
|
||||
@@ -2220,9 +2365,8 @@ export function getSessions() {
|
||||
export function getCurrentModel() {
|
||||
const sess = sessions.find(x => x.id === currentSessionId);
|
||||
if (sess && sess.model) return sess.model;
|
||||
// Pending session not yet materialized — read from model picker label
|
||||
const label = document.getElementById('model-picker-label');
|
||||
return label ? label.textContent.trim() : null;
|
||||
if (_pendingChat && _pendingChat.modelId) return _pendingChat.modelId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Endpoint URL serving the current (or pending) session's model. Used to
|
||||
@@ -2237,6 +2381,7 @@ export function getCurrentEndpointUrl() {
|
||||
export function setCurrentSessionId(id) {
|
||||
_sessionNavToken++;
|
||||
currentSessionId = id;
|
||||
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
|
||||
if (!id) {
|
||||
_suppressNextSessionLoading = true;
|
||||
Storage.remove('lastSessionId');
|
||||
@@ -2247,6 +2392,41 @@ export function setCurrentSessionId(id) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCurrentSessionFromTopMenu() {
|
||||
const sid = currentSessionId;
|
||||
if (!sid) {
|
||||
uiModule.showToast('No chat to delete');
|
||||
return false;
|
||||
}
|
||||
const session = sessions.find(s => String(s.id) === String(sid));
|
||||
if (session?.is_important) {
|
||||
uiModule.showToast('Unfavorite before deleting');
|
||||
return false;
|
||||
}
|
||||
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
|
||||
return false;
|
||||
}
|
||||
if (window.chatModule && window.chatModule.abortCurrentRequest) {
|
||||
window.chatModule.abortCurrentRequest();
|
||||
}
|
||||
_deselectCurrentSession(sid);
|
||||
_removeSessionFromLocalState(sid);
|
||||
_skipAutoSelect = true;
|
||||
try {
|
||||
const pm = await import('./presets.js');
|
||||
if (pm.removePersistentChat) pm.removePersistentChat(sid);
|
||||
} catch (e) {}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
uiModule.showToast('Session deleted');
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to delete session');
|
||||
}
|
||||
await loadSessions();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Session list keyboard navigation: arrows to move, Delete to delete
|
||||
async function _onSessionListKeydown(e) {
|
||||
const item = e.target.closest('.list-item[data-session-id]');
|
||||
@@ -3454,6 +3634,7 @@ const sessionModule = {
|
||||
selectSession,
|
||||
createDirectChat,
|
||||
materializePendingSession,
|
||||
preMaterializePendingSession,
|
||||
hasPendingChat,
|
||||
getPendingChat,
|
||||
getCurrentSessionId,
|
||||
@@ -3475,7 +3656,8 @@ const sessionModule = {
|
||||
closeArchive,
|
||||
setSessionHasDocs,
|
||||
getSortMode,
|
||||
setSortMode
|
||||
setSortMode,
|
||||
deleteCurrentSessionFromTopMenu
|
||||
};
|
||||
|
||||
export { updateModelPicker };
|
||||
|
||||
+30
-11
@@ -22,7 +22,7 @@ function safeRasterDataUrl(raw) {
|
||||
}
|
||||
|
||||
/* ── Tab switching ── */
|
||||
const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
|
||||
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
|
||||
|
||||
function initTabs() {
|
||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
|
||||
@@ -792,8 +792,9 @@ async function initImageSettings() {
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }) });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
@@ -1860,7 +1861,7 @@ const SHORTCUT_ICONS = {
|
||||
settings: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
|
||||
focus_input: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||
open_calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
|
||||
open_compare: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>',
|
||||
open_compare: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>',
|
||||
open_cookbook: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||
open_research: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
|
||||
open_gallery: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
|
||||
@@ -2821,6 +2822,17 @@ async function initReminderSettings() {
|
||||
async function initEmailAccountsSettings() {
|
||||
const root = el('settings-modal');
|
||||
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
|
||||
|
||||
el('set-email-open-library-settings')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1');
|
||||
if (typeof mod.openEmailLibrarySettings === 'function') {
|
||||
await mod.openEmailLibrarySettings();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to open Email settings page', e);
|
||||
}
|
||||
});
|
||||
const manageBtn = el('set-email-open-integrations');
|
||||
if (manageBtn && manageBtn.dataset.bound !== '1') {
|
||||
manageBtn.dataset.bound = '1';
|
||||
@@ -3111,24 +3123,31 @@ async function initEmailSettings() {
|
||||
const root = el('settings-modal');
|
||||
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
|
||||
|
||||
const styleKey = 'odysseus-email-writing-style';
|
||||
const styleKey = () => {
|
||||
const account = String(window.__odysseusActiveEmailAccount || '').trim();
|
||||
return account ? `odysseus-email-writing-style:${account}` : 'odysseus-email-writing-style';
|
||||
};
|
||||
const styleEl = el('set-email-style');
|
||||
const emailAccountSuffix = () => {
|
||||
const account = String(window.__odysseusActiveEmailAccount || '').trim();
|
||||
return account ? `?account_id=${encodeURIComponent(account)}` : '';
|
||||
};
|
||||
|
||||
// The account/CardDAV config endpoints can be slow when remote mail servers
|
||||
// are cold. Populate the Writing Style box independently so saved prose does
|
||||
// not appear seconds after the panel opens.
|
||||
try {
|
||||
const cachedStyle = localStorage.getItem(styleKey);
|
||||
const cachedStyle = localStorage.getItem(styleKey());
|
||||
if (styleEl && cachedStyle !== null && !styleEl.value) styleEl.value = cachedStyle;
|
||||
} catch (_) {}
|
||||
|
||||
const loadWritingStyle = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/email/style');
|
||||
const res = await fetch(`/api/email/style${emailAccountSuffix()}`);
|
||||
const data = await res.json();
|
||||
const style = data.style || '';
|
||||
if (styleEl) styleEl.value = style;
|
||||
try { localStorage.setItem(styleKey, style); } catch (_) {}
|
||||
try { localStorage.setItem(styleKey(), style); } catch (_) {}
|
||||
} catch (_) {}
|
||||
};
|
||||
loadWritingStyle();
|
||||
@@ -3240,7 +3259,7 @@ async function initEmailSettings() {
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/email/extract-style', {
|
||||
const res = await fetch(`/api/email/extract-style${emailAccountSuffix()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sample_count: 15 }),
|
||||
@@ -3248,7 +3267,7 @@ async function initEmailSettings() {
|
||||
const data = await res.json();
|
||||
if (data.success && data.style) {
|
||||
if (styleEl) styleEl.value = data.style;
|
||||
try { localStorage.setItem(styleKey, data.style); } catch (_) {}
|
||||
try { localStorage.setItem(styleKey(), data.style); } catch (_) {}
|
||||
if (msg) msg.textContent = '✓ Style extracted';
|
||||
} else {
|
||||
if (msg) msg.textContent = data.error || 'Failed';
|
||||
@@ -3268,14 +3287,14 @@ async function initEmailSettings() {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const style = styleEl ? styleEl.value : '';
|
||||
const res = await fetch('/api/email/style', {
|
||||
const res = await fetch(`/api/email/style${emailAccountSuffix()}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ style }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
try { localStorage.setItem(styleKey, style); } catch (_) {}
|
||||
try { localStorage.setItem(styleKey(), style); } catch (_) {}
|
||||
}
|
||||
if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
|
||||
@@ -34,6 +34,46 @@ export function initSidebarLayout(Storage, opts) {
|
||||
// ── Icon rail + sidebar toggle ──
|
||||
const iconRail = document.getElementById('icon-rail');
|
||||
const hamburgerBtn = document.getElementById('hamburger-btn');
|
||||
const SIDEBAR_MODE_KEY = 'odysseus-sidebar-mode';
|
||||
|
||||
function _setSidebarModeClasses(mode) {
|
||||
document.documentElement.classList.remove('ody-mobile-startup-sidebar-hidden');
|
||||
document.documentElement.classList.toggle('ody-sidebar-mini', mode === 'mini');
|
||||
document.documentElement.classList.toggle('ody-sidebar-off', mode === 'off');
|
||||
}
|
||||
|
||||
function _saveSidebarMode(mode) {
|
||||
try { localStorage.setItem(SIDEBAR_MODE_KEY, mode); } catch (_) {}
|
||||
_setSidebarModeClasses(mode);
|
||||
}
|
||||
|
||||
function _applyStoredSidebarMode() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar) return;
|
||||
if (window.innerWidth < 768 && document.getElementById('app-loader')) {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) {
|
||||
iconRail.classList.add('rail-hidden');
|
||||
iconRail.classList.remove('mobile-mini');
|
||||
iconRail.style.cssText = '';
|
||||
}
|
||||
_setSidebarModeClasses('off');
|
||||
return;
|
||||
}
|
||||
let mode = 'full';
|
||||
try { mode = localStorage.getItem(SIDEBAR_MODE_KEY) || 'full'; } catch (_) {}
|
||||
if (mode === 'mini') {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) iconRail.classList.remove('rail-hidden');
|
||||
} else if (mode === 'off') {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) iconRail.classList.add('rail-hidden');
|
||||
} else {
|
||||
sidebar.classList.remove('hidden');
|
||||
if (iconRail) iconRail.classList.remove('rail-hidden');
|
||||
}
|
||||
_setSidebarModeClasses(mode);
|
||||
}
|
||||
|
||||
function _syncRailSideCore() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
@@ -62,6 +102,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
document.body.classList.toggle('hamburger-left', !isRight);
|
||||
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
|
||||
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
|
||||
_setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini'));
|
||||
}
|
||||
// Keep incognito button clear of hamburger
|
||||
const incogBtn = document.getElementById('incognito-btn');
|
||||
@@ -82,6 +123,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
|
||||
document.getElementById('sidebar').classList.add('right-side');
|
||||
}
|
||||
_applyStoredSidebarMode();
|
||||
syncRailSide();
|
||||
|
||||
// In-sidebar toggle button — same behavior as hamburger
|
||||
@@ -154,6 +196,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
if (isSidebarVisible) {
|
||||
// Closing sidebar
|
||||
sidebar.classList.add('hidden');
|
||||
_saveSidebarMode('off');
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
} else {
|
||||
// Mobile: the hamburger always opens the sidebar from the RIGHT.
|
||||
@@ -169,11 +212,13 @@ export function initSidebarLayout(Storage, opts) {
|
||||
// Wait for keyboard dismiss to settle, then open
|
||||
setTimeout(() => {
|
||||
sidebar.classList.remove('hidden');
|
||||
_saveSidebarMode('full');
|
||||
if (backdrop) backdrop.classList.add('visible');
|
||||
syncRailSide();
|
||||
}, 250);
|
||||
} else {
|
||||
sidebar.classList.remove('hidden');
|
||||
_saveSidebarMode('full');
|
||||
if (backdrop) backdrop.classList.add('visible');
|
||||
}
|
||||
}
|
||||
@@ -184,10 +229,13 @@ export function initSidebarLayout(Storage, opts) {
|
||||
// Desktop: full sidebar ↔ mini (icon rail) — simple toggle
|
||||
if (isSidebarVisible) {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) iconRail.classList.remove('rail-hidden');
|
||||
_saveSidebarMode('mini');
|
||||
} else {
|
||||
_wasAutoCollapsed = false;
|
||||
iconRail.classList.remove('rail-hidden');
|
||||
sidebar.classList.remove('hidden');
|
||||
_saveSidebarMode('full');
|
||||
}
|
||||
syncRailSide();
|
||||
});
|
||||
@@ -484,7 +532,7 @@ function _initChatSwipeToOpenSidebar() {
|
||||
|
||||
// Areas where a horizontal drag means something else (their own scroll/drag).
|
||||
const EXCLUDE = [
|
||||
'#sidebar', '#icon-rail', '.modal', '.input-bar', '#message',
|
||||
'#sidebar', '#icon-rail', '.modal', '.input-bar', '.chat-input-bar', '#message',
|
||||
'#minimized-dock', '.minimized-dock-chip', '#dock-trash-zone',
|
||||
'pre', 'table', '.agent-tool-output', '.agent-thread-cmd',
|
||||
'input', 'textarea', 'select',
|
||||
|
||||
@@ -16,7 +16,7 @@ import modelsModule from './models.js';
|
||||
import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
@@ -287,8 +287,14 @@ function _setupProviderPrompt() {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Persist a message to the current session (fire-and-forget) */
|
||||
function _persistMsg(role, content, metadata) {
|
||||
const sid = sessionModule.getCurrentSessionId();
|
||||
async function _persistMsg(role, content, metadata) {
|
||||
let sid = sessionModule.getCurrentSessionId();
|
||||
if (!sid && sessionModule.hasPendingChat?.()) {
|
||||
try {
|
||||
await sessionModule.materializePendingSession?.();
|
||||
sid = sessionModule.getCurrentSessionId();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!sid || !content) return;
|
||||
const payload = { role, content };
|
||||
if (metadata) payload.metadata = metadata;
|
||||
@@ -300,6 +306,7 @@ function _persistMsg(role, content, metadata) {
|
||||
}
|
||||
|
||||
function slashReply(text) {
|
||||
_hideWelcomeScreen();
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg msg-ai';
|
||||
@@ -1262,7 +1269,7 @@ async function _cmdWorkspace(args, ctx) {
|
||||
// folder, sensitive dir, filesystem root).
|
||||
workspaceModule.vetAndSetWorkspace(rest).then(({ ok, path }) => {
|
||||
if (ok) slashReply(`Workspace set: <code>${uiModule.esc(path)}</code>`);
|
||||
else slashReply(`Not a usable workspace folder: <code>${uiModule.esc(rest)}</code>. It must be an existing directory, not a filesystem root or sensitive path.`);
|
||||
else slashReply(`Not a usable workspace folder on the Odysseus backend: <code>${uiModule.esc(rest)}</code>. If Odysseus is running in Docker, use the container path, usually <code>/app</code>, or use <code>/workspace pick</code>.`);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
+24
-40
@@ -152,7 +152,7 @@ class Spinner {
|
||||
|
||||
this._wpCanvas = canvas;
|
||||
this._wpCtx = canvas.getContext('2d');
|
||||
this._wpFrame = 60;
|
||||
this._wpStartedAt = null;
|
||||
this.element = wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
@@ -164,12 +164,12 @@ class Spinner {
|
||||
const cx = W / 2, cy = H / 2;
|
||||
const maxR = Math.min(W, H) / 2 - 1;
|
||||
const lw = W > 30 ? 3 : W > 20 ? 2 : 1.5;
|
||||
const TOTAL_TURNS = 4;
|
||||
const TAIL_LEN = 0.45;
|
||||
const SPIN_SPEED = 0.08;
|
||||
const LAYERS = 12;
|
||||
const STEPS = 50;
|
||||
const t = this._wpFrame;
|
||||
const TOTAL_TURNS = 2.7;
|
||||
const STEPS = 84;
|
||||
const LOOP_MS = 1100;
|
||||
if (!this._wpStartedAt) this._wpStartedAt = performance.now();
|
||||
const loop = ((performance.now() - this._wpStartedAt) % LOOP_MS) / LOOP_MS;
|
||||
const rot = loop * Math.PI * 2;
|
||||
|
||||
// Colors from CSS vars — read ONCE and cache. Calling getComputedStyle every
|
||||
// frame forces a full style recalc per frame, which janks/freezes the canvas
|
||||
@@ -185,8 +185,9 @@ class Spinner {
|
||||
const fg = this._wpColors.fg;
|
||||
const track = this._wpColors.track;
|
||||
|
||||
function spiralPoint(frac, rot) {
|
||||
const r = maxR * (1 - frac);
|
||||
function spiralPoint(frac) {
|
||||
const eased = Math.pow(frac, 0.82);
|
||||
const r = maxR * eased;
|
||||
const angle = frac * TOTAL_TURNS * Math.PI * 2 + rot;
|
||||
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
|
||||
}
|
||||
@@ -202,49 +203,32 @@ class Spinner {
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const headPos = (t * 0.008) % 1;
|
||||
|
||||
// overlapping sub-paths for smooth fade
|
||||
// Rotating a single continuous spiral keeps the loop seamless: the start
|
||||
// and end frames are the same shape, just one full turn apart.
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
for (let layer = LAYERS - 1; layer >= 0; layer--) {
|
||||
const endFrac = (layer + 1) / LAYERS;
|
||||
const stepsForLayer = Math.ceil(STEPS * endFrac);
|
||||
const alpha = Math.pow(1 - endFrac, 2) * 0.7;
|
||||
|
||||
for (let i = 1; i <= STEPS; i++) {
|
||||
const a = (i - 1) / STEPS;
|
||||
const b = i / STEPS;
|
||||
const p0 = spiralPoint(a);
|
||||
const p1 = spiralPoint(b);
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
let prevPos = -1;
|
||||
for (let i = 0; i <= stepsForLayer; i++) {
|
||||
const frac = i / STEPS;
|
||||
let pos = headPos - frac * TAIL_LEN;
|
||||
if (pos < 0) pos += 1;
|
||||
if (started && prevPos < 0.3 && pos > 0.7) {
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
started = false;
|
||||
}
|
||||
const pt = spiralPoint(pos, t * SPIN_SPEED);
|
||||
if (!started) { ctx.moveTo(pt.x, pt.y); started = true; }
|
||||
else ctx.lineTo(pt.x, pt.y);
|
||||
prevPos = pos;
|
||||
}
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
ctx.lineTo(p1.x, p1.y);
|
||||
ctx.strokeStyle = fg;
|
||||
ctx.lineWidth = lw * 0.8;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = lw * (0.52 + b * 0.32);
|
||||
ctx.globalAlpha = 0.12 + Math.pow(b, 1.8) * 0.72;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// bright dot at head
|
||||
const head = spiralPoint(headPos, t * SPIN_SPEED);
|
||||
const head = spiralPoint(1);
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, Math.max(1, lw * 0.45), 0, Math.PI * 2);
|
||||
ctx.arc(head.x, head.y, Math.max(1.05, lw * 0.48), 0, Math.PI * 2);
|
||||
ctx.fillStyle = fg;
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
this._wpFrame++;
|
||||
if (!this.isRunning) return;
|
||||
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
|
||||
// got removed (e.g. a loading row replaced by results). But keep spinning
|
||||
@@ -293,7 +277,7 @@ class Spinner {
|
||||
}
|
||||
|
||||
if (this.animation === 'whirlpool') {
|
||||
this._wpFrame = 60;
|
||||
this._wpStartedAt = performance.now();
|
||||
this._drawWhirlpool();
|
||||
return;
|
||||
}
|
||||
|
||||
+271
-33
@@ -20,6 +20,8 @@ let _escHandler = null;
|
||||
let _viewingRuns = null; // task id when viewing run history
|
||||
let _clockInterval = null;
|
||||
let _taskFailurePending = false;
|
||||
let _taskCompletionPending = false;
|
||||
let _taskBulkDeleting = false;
|
||||
|
||||
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
@@ -29,6 +31,12 @@ function _setTaskFailurePending(active) {
|
||||
document.getElementById('rail-tasks')?.classList.toggle('task-failure-pending', _taskFailurePending);
|
||||
}
|
||||
|
||||
function _setTaskCompletionPending(active) {
|
||||
_taskCompletionPending = !!active;
|
||||
document.getElementById('tool-tasks-btn')?.classList.toggle('task-completion-pending', _taskCompletionPending);
|
||||
document.getElementById('rail-tasks')?.classList.toggle('task-completion-pending', _taskCompletionPending);
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
|
||||
async function _fetchTasks() {
|
||||
@@ -105,6 +113,25 @@ function _animateTaskRemoval(ids) {
|
||||
return new Promise(resolve => setTimeout(resolve, 520));
|
||||
}
|
||||
|
||||
function _setTaskCardsDeleting(ids, active) {
|
||||
for (const id of ids) {
|
||||
const card = _taskCardById(id);
|
||||
if (!card) continue;
|
||||
card.classList.toggle('task-card-deleting', !!active);
|
||||
const existing = card.querySelector('.task-card-delete-busy');
|
||||
if (!active) {
|
||||
existing?.remove();
|
||||
continue;
|
||||
}
|
||||
if (!existing) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'task-card-delete-busy';
|
||||
badge.innerHTML = '<span class="task-card-delete-busy-label">Deleting</span><span class="task-card-delete-busy-spin" aria-hidden="true"></span>';
|
||||
card.appendChild(badge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _pauseTask(id) {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/${id}/pause`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
@@ -669,17 +696,65 @@ function _taskUpdateBulkCount() {
|
||||
}
|
||||
async function _taskBulkDelete() {
|
||||
const ids = [..._taskSelected];
|
||||
if (!ids.length) return;
|
||||
if (!ids.length || _taskBulkDeleting) return;
|
||||
const ok = uiModule?.styledConfirm
|
||||
? await uiModule.styledConfirm(`Delete ${ids.length} task${ids.length > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })
|
||||
: confirm(`Delete ${ids.length} task(s)?`);
|
||||
if (!ok) return;
|
||||
const results = await Promise.allSettled(ids.map(id => _deleteTask(id)));
|
||||
const deletedIds = ids.filter((_, i) => results[i].status === 'fulfilled');
|
||||
_taskBulkDeleting = true;
|
||||
const countEl = document.getElementById('tasks-selected-count');
|
||||
const deleteBtn = document.getElementById('tasks-bulk-delete');
|
||||
const cancelBtn = document.getElementById('tasks-bulk-cancel');
|
||||
const selectAll = document.getElementById('tasks-select-all');
|
||||
const originalDeleteHtml = deleteBtn?.innerHTML || '';
|
||||
let busySpinner = null;
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.classList.add('tasks-bulk-loading');
|
||||
deleteBtn.innerHTML = '<span class="tasks-bulk-loading-label">Deleting</span>';
|
||||
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
const spEl = busySpinner.createElement();
|
||||
spEl.classList.add('tasks-bulk-whirlpool');
|
||||
deleteBtn.appendChild(spEl);
|
||||
busySpinner.start();
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `Deleting 0/${ids.length}…`;
|
||||
_setTaskCardsDeleting(ids, true);
|
||||
const deletedIds = [];
|
||||
let finished = 0;
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map(async (id) => {
|
||||
try {
|
||||
await _deleteTask(id);
|
||||
deletedIds.push(id);
|
||||
} finally {
|
||||
finished += 1;
|
||||
if (countEl) countEl.textContent = `Deleting ${finished}/${ids.length}…`;
|
||||
}
|
||||
}));
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
await _animateTaskRemoval(deletedIds);
|
||||
if (uiModule) uiModule.showToast(`Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`);
|
||||
if (uiModule) {
|
||||
const msg = failed
|
||||
? `Deleted ${deletedIds.length}, failed ${failed}`
|
||||
: `Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`;
|
||||
uiModule.showToast(msg);
|
||||
}
|
||||
await _fetchTasks();
|
||||
} finally {
|
||||
_setTaskCardsDeleting(ids, false);
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
if (deleteBtn) {
|
||||
deleteBtn.classList.remove('tasks-bulk-loading');
|
||||
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (selectAll) selectAll.disabled = false;
|
||||
_taskBulkDeleting = false;
|
||||
_taskExitSelect(); // clears selection + re-renders the fresh list
|
||||
}
|
||||
}
|
||||
|
||||
// Category filter chips (library-style tags) — solo-select: click one to
|
||||
@@ -1937,10 +2012,93 @@ function _switchTab(tab) {
|
||||
b.classList.toggle('active', on);
|
||||
});
|
||||
if (tab === 'tasks') _renderMainView();
|
||||
else if (tab === 'completed') _renderCompletedView();
|
||||
else if (tab === 'activity') _renderActivityView();
|
||||
else if (tab === 'new') _showPresetPicker();
|
||||
}
|
||||
|
||||
function _runToActivityEntry(r) {
|
||||
let resultText = r.result || r.error || '';
|
||||
if (!resultText) {
|
||||
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
|
||||
if (r.status === 'running') resultText = '_Running…_';
|
||||
}
|
||||
return {
|
||||
kind: r.task_type || 'llm',
|
||||
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
|
||||
taskId: r.task_id,
|
||||
action: r.action || '',
|
||||
result: resultText,
|
||||
prompt: '',
|
||||
ts: r.finished_at || r.started_at,
|
||||
status: r.status,
|
||||
model: r.model || '',
|
||||
endpointUrl: r.endpoint_url || '',
|
||||
sessionId: r.session_id || '',
|
||||
researchId: r.research_id || '',
|
||||
output_target: r.output_target || 'session',
|
||||
};
|
||||
}
|
||||
|
||||
function _isFinishedRun(entry) {
|
||||
return !['queued', 'running', 'skipped'].includes(entry.status || '');
|
||||
}
|
||||
|
||||
function _isChatResultRun(entry) {
|
||||
return _isFinishedRun(entry)
|
||||
&& (entry.kind === 'llm' || entry.kind === 'research')
|
||||
&& !!(entry.result || '').trim();
|
||||
}
|
||||
|
||||
async function _renderCompletedView() {
|
||||
_setTaskCompletionPending(false);
|
||||
const modal = document.getElementById('tasks-modal');
|
||||
const body = modal?.querySelector('.modal-body');
|
||||
if (!body) return;
|
||||
body.innerHTML = `
|
||||
<div class="admin-card tasks-activity-card tasks-completed-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0;">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;">Completed</h2>
|
||||
<button class="memory-toolbar-btn" id="tasks-completed-refresh" title="Refresh" style="margin-left:auto;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
</div>
|
||||
<p class="memory-desc">Completed assistant/research outputs you can open in chat.</p>
|
||||
<div id="tasks-completed-list" class="memory-list tasks-activity-list tasks-completed-list" style="flex:1;overflow:auto;font-size:13px;min-height:0;"></div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('tasks-completed-refresh')?.addEventListener('click', _renderCompletedView);
|
||||
const list = document.getElementById('tasks-completed-list');
|
||||
list?.appendChild(spinnerModule.createLoadingRow('Loading…'));
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=${_completedLimit}&max_result_chars=10000`, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const finished = (data.runs || []).map(_runToActivityEntry).filter(_isChatResultRun);
|
||||
_completedHasMore = !!data.has_more && _completedLimit < 200;
|
||||
_activityEntries = finished;
|
||||
_syncCompletedTabCount(finished.length);
|
||||
if (!list) return;
|
||||
if (finished.length === 0) {
|
||||
list.innerHTML = '<div class="doclib-empty task-completed-empty">No completed task outputs yet.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = finished.map(_renderCompletedPreviewEntry).join('');
|
||||
if (_completedHasMore) {
|
||||
list.insertAdjacentHTML('beforeend', `
|
||||
<button type="button" class="memory-toolbar-btn tasks-activity-load-more" id="tasks-completed-load-more" style="width:100%;justify-content:center;margin-top:6px;">
|
||||
Load more
|
||||
</button>
|
||||
`);
|
||||
list.querySelector('#tasks-completed-load-more')?.addEventListener('click', () => {
|
||||
_completedLimit = Math.min(200, _completedLimit + 40);
|
||||
_renderCompletedView();
|
||||
});
|
||||
}
|
||||
_wireCompletedPreviewRows(list);
|
||||
} catch (e) {
|
||||
if (list) list.innerHTML = `<div style="opacity:0.5;padding:12px;">Failed to load completed tasks: ${_escHtml(e.message || String(e))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Activity view (assistant session log) ----
|
||||
|
||||
async function _renderActivityView() {
|
||||
@@ -2082,32 +2240,8 @@ async function _renderActivityView() {
|
||||
list.innerHTML = '<div style="opacity:0.5;padding:12px;">No activity yet. Scheduled tasks will log here once they run.</div>';
|
||||
return;
|
||||
}
|
||||
_activityEntries = runs.map(r => {
|
||||
let resultText = r.result || r.error || '';
|
||||
if (!resultText) {
|
||||
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
|
||||
if (r.status === 'running') resultText = '_Running…_';
|
||||
}
|
||||
return {
|
||||
// Surface the actual task_type ('llm' | 'research' | 'action') so the
|
||||
// chat-worthy check in _renderActivityEntry can decide between "Open
|
||||
// in chat" (llm/research) and "Copy log" (action). Was hardcoded
|
||||
// 'task', which never matched and made Open-in-chat dead code.
|
||||
kind: r.task_type || 'llm',
|
||||
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
|
||||
taskId: r.task_id,
|
||||
action: r.action || '',
|
||||
result: resultText,
|
||||
prompt: '',
|
||||
ts: r.finished_at || r.started_at,
|
||||
status: r.status,
|
||||
model: r.model || '',
|
||||
endpointUrl: r.endpoint_url || '',
|
||||
sessionId: r.session_id || '',
|
||||
researchId: r.research_id || '',
|
||||
output_target: r.output_target || 'session',
|
||||
};
|
||||
});
|
||||
_activityEntries = runs.map(_runToActivityEntry);
|
||||
_syncCompletedTabCount(_activityEntries.filter(_isChatResultRun).length);
|
||||
_buildChips();
|
||||
_applyFilter();
|
||||
} catch (e) {
|
||||
@@ -2119,6 +2253,13 @@ async function _renderActivityView() {
|
||||
let _activityEntries = [];
|
||||
let _activityLimit = 40;
|
||||
let _activityHasMore = false;
|
||||
let _completedLimit = 40;
|
||||
let _completedHasMore = false;
|
||||
|
||||
function _syncCompletedTabCount(count) {
|
||||
const el = document.getElementById('tasks-completed-tab-count');
|
||||
if (el) el.textContent = String(count || 0);
|
||||
}
|
||||
|
||||
function _stackActivityEntries(entries) {
|
||||
const out = [];
|
||||
@@ -2291,6 +2432,86 @@ function _wireActivityRows(list) {
|
||||
});
|
||||
}
|
||||
|
||||
function _renderCompletedPreviewEntry(entry) {
|
||||
const entryIdx = _activityEntries.indexOf(entry);
|
||||
const tsLabel = _relativeTime(entry.ts);
|
||||
const tsAbs = entry.ts ? new Date(entry.ts).toLocaleString() : '';
|
||||
const modelTag = entry.model
|
||||
? `<span class="doclib-chat-msg-model">${_escHtml(entry.model.split('/').pop())}</span>`
|
||||
: '';
|
||||
const raw = (entry.result || '').trim();
|
||||
const truncated = raw.length > 1400 ? raw.slice(0, 1400) + '…' : raw;
|
||||
const cleaned = truncated
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
||||
.replace(/<think>[\s\S]*$/, '')
|
||||
.trim();
|
||||
let body;
|
||||
try {
|
||||
body = markdownModule.mdToHtml(cleaned);
|
||||
} catch {
|
||||
body = _escHtml(cleaned);
|
||||
}
|
||||
const title = _escHtml(entry.taskName || 'Task');
|
||||
const time = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
|
||||
return `
|
||||
<div class="memory-item doclib-chat-row task-completed-preview-row" data-entry-idx="${entryIdx}">
|
||||
<div class="doclib-chat-header task-completed-preview-head">
|
||||
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
|
||||
<span class="task-log-name">${title}</span>${_taskAiMark(entry)}
|
||||
<span style="flex:1"></span>
|
||||
${time}
|
||||
</div>
|
||||
<div class="doclib-chat-preview task-completed-chat-preview" style="display:block;">
|
||||
<div class="doclib-chat-preview-messages">
|
||||
<div class="doclib-chat-bubble-row assistant">
|
||||
<div class="doclib-chat-bubble">
|
||||
${modelTag}
|
||||
<div class="doclib-chat-bubble-body">${body}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doclib-chat-preview-actions">
|
||||
<button class="doclib-chat-copy-btn task-completed-copy-btn" type="button">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy
|
||||
</button>
|
||||
<button class="doclib-chat-open-btn task-completed-open-chat" type="button">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||||
Open chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _wireCompletedPreviewRows(list) {
|
||||
list.querySelectorAll('.task-completed-open-chat').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const row = btn.closest('.task-completed-preview-row');
|
||||
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
|
||||
const entry = _activityEntries[idx];
|
||||
if (entry) _openResultInChat(entry);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.task-completed-copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const row = btn.closest('.task-completed-preview-row');
|
||||
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
|
||||
const entry = _activityEntries[idx];
|
||||
if (!entry) return;
|
||||
try {
|
||||
uiModule.copyToClipboard((entry.result || '').trim());
|
||||
uiModule.showToast('Output copied');
|
||||
} catch (_) {
|
||||
uiModule.showError('Copy failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Open a task run's result in a fresh chat session so it's comfortable
|
||||
// to read full-width and the user can ask follow-ups.
|
||||
async function _openResultInChat(entry) {
|
||||
@@ -2422,7 +2643,7 @@ function _categoryLabel(taskName) {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function _renderActivityEntry(entry) {
|
||||
function _renderActivityEntry(entry, opts = {}) {
|
||||
// Canonical index into _activityEntries (map() passes the FILTERED
|
||||
// index, which would be wrong) — used by the Open-in-chat handler.
|
||||
const entryIdx = Number.isInteger(entry.sourceIdx) ? entry.sourceIdx : _activityEntries.indexOf(entry);
|
||||
@@ -2568,7 +2789,7 @@ function _renderActivityEntry(entry) {
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
|
||||
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}${opts.expanded ? ' expanded' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
|
||||
<div class="task-log-row-head">
|
||||
${statusDot}
|
||||
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
|
||||
@@ -2715,10 +2936,13 @@ export function openTasks(focusId, opts) {
|
||||
startNotificationPolling();
|
||||
const o = opts || {};
|
||||
const openActivityForFailure = _taskFailurePending && !focusId && o.filter === undefined;
|
||||
const openCompletedForNotification = _taskCompletionPending && !focusId && o.filter === undefined;
|
||||
_setTaskFailurePending(false);
|
||||
_setTaskCompletionPending(false);
|
||||
if (_open) {
|
||||
// Already open — just focus the requested task / apply filter.
|
||||
if (openActivityForFailure) _switchTab('activity');
|
||||
else if (openCompletedForNotification) _switchTab('completed');
|
||||
if (o.filter !== undefined) { _taskFilter = o.filter; _renderList(); }
|
||||
if (focusId) _focusTask(focusId);
|
||||
return;
|
||||
@@ -2751,6 +2975,10 @@ export function openTasks(focusId, opts) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Activity
|
||||
</button>
|
||||
<button class="memory-tab tasks-tab" data-tab="completed" role="tab" aria-selected="false">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
Completed <span id="tasks-completed-tab-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span>
|
||||
</button>
|
||||
<button class="memory-tab tasks-tab" data-tab="new" role="tab" aria-selected="false">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
||||
Add
|
||||
@@ -2825,7 +3053,7 @@ export function openTasks(focusId, opts) {
|
||||
// of an empty modal-body that fills in after the fetch resolves — that delay
|
||||
// was visible as a "flicker" right after opening.
|
||||
_activeTab = 'tasks';
|
||||
_switchTab(openActivityForFailure ? 'activity' : 'tasks');
|
||||
_switchTab(openActivityForFailure ? 'activity' : openCompletedForNotification ? 'completed' : 'tasks');
|
||||
_fetchTasks().then(() => {
|
||||
// Re-render so the list swaps the Loading row for real cards.
|
||||
_renderList();
|
||||
@@ -2901,6 +3129,15 @@ async function _pollTaskNotifications() {
|
||||
const notes = data.notifications || [];
|
||||
for (const n of notes) {
|
||||
const ok = n.status === 'success';
|
||||
if (ok) {
|
||||
const completedOpen = _open && document.querySelector('.tasks-tab.active[data-tab="completed"]');
|
||||
if (completedOpen) {
|
||||
_setTaskCompletionPending(false);
|
||||
_renderCompletedView();
|
||||
} else {
|
||||
_setTaskCompletionPending(true);
|
||||
}
|
||||
}
|
||||
// Tasks with output_target='notification' carry the result text in `body`
|
||||
// — show it as a real browser Notification (richer than a toast). Falls
|
||||
// back to a toast when permission is denied or unavailable.
|
||||
@@ -2934,6 +3171,7 @@ async function _pollTaskNotifications() {
|
||||
|
||||
function startNotificationPolling() {
|
||||
if (_notifInterval) return;
|
||||
setTimeout(_pollTaskNotifications, 1500);
|
||||
_notifInterval = setInterval(_pollTaskNotifications, 30000);
|
||||
}
|
||||
|
||||
|
||||
+24
-6
@@ -577,9 +577,11 @@ export function el(id) {
|
||||
|
||||
/**
|
||||
* Styled confirm dialog — replaces native browser confirm().
|
||||
* Returns a Promise<boolean>.
|
||||
* Returns a Promise<boolean|'alternate'>. Existing two-button callers only
|
||||
* receive true/false; callers that pass alternateText can detect the third
|
||||
* action via the string 'alternate'.
|
||||
*/
|
||||
export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
|
||||
export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', alternateText = '', title = 'Confirm', danger = false } = {}) {
|
||||
return new Promise(resolve => {
|
||||
// Reuse or create the modal
|
||||
let overlay = document.getElementById('styled-confirm-overlay');
|
||||
@@ -593,6 +595,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
'<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button id="styled-confirm-cancel"></button>' +
|
||||
'<button id="styled-confirm-alt" style="display:none;"></button>' +
|
||||
'<button id="styled-confirm-ok"></button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
@@ -600,14 +603,25 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
}
|
||||
|
||||
const msgEl = document.getElementById('styled-confirm-msg');
|
||||
const titleEl = document.getElementById('styled-confirm-title');
|
||||
const okBtn = document.getElementById('styled-confirm-ok');
|
||||
const cancelBtn = document.getElementById('styled-confirm-cancel');
|
||||
let altBtn = document.getElementById('styled-confirm-alt');
|
||||
if (!altBtn) {
|
||||
altBtn = document.createElement('button');
|
||||
altBtn.id = 'styled-confirm-alt';
|
||||
okBtn.parentNode.insertBefore(altBtn, okBtn);
|
||||
}
|
||||
|
||||
if (titleEl) titleEl.textContent = title || 'Confirm';
|
||||
msgEl.textContent = message;
|
||||
okBtn.textContent = confirmText;
|
||||
cancelBtn.textContent = cancelText;
|
||||
altBtn.textContent = alternateText || '';
|
||||
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
|
||||
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
|
||||
altBtn.className = 'confirm-btn confirm-btn-secondary';
|
||||
altBtn.style.display = alternateText ? '' : 'none';
|
||||
|
||||
// Remember what had focus so we can restore it when the dialog closes.
|
||||
const _prevFocus = document.activeElement;
|
||||
@@ -619,20 +633,23 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
overlay.style.display = 'none';
|
||||
okBtn.removeEventListener('click', onOk);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
altBtn.removeEventListener('click', onAlt);
|
||||
overlay.removeEventListener('click', onBackdrop);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
|
||||
resolve(result);
|
||||
}
|
||||
function onOk() { cleanup(true); }
|
||||
function onAlt() { cleanup('alternate'); }
|
||||
function onCancel() { cleanup(false); }
|
||||
function onBackdrop(e) { if (e.target === overlay) cleanup(false); }
|
||||
function onKey(e) {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const active = document.activeElement;
|
||||
if (active === okBtn) cancelBtn.focus();
|
||||
else okBtn.focus();
|
||||
const f = alternateText ? [cancelBtn, altBtn, okBtn] : [cancelBtn, okBtn];
|
||||
const i = f.indexOf(document.activeElement);
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
||||
f[(i + dir + f.length) % f.length].focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -641,7 +658,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
} else if (e.key === 'Tab') {
|
||||
// Trap focus inside the dialog so Tab can't wander to the page behind.
|
||||
e.preventDefault();
|
||||
const f = [cancelBtn, okBtn];
|
||||
const f = alternateText ? [cancelBtn, altBtn, okBtn] : [cancelBtn, okBtn];
|
||||
const i = f.indexOf(document.activeElement);
|
||||
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
|
||||
f[n].focus();
|
||||
@@ -649,6 +666,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
}
|
||||
|
||||
okBtn.addEventListener('click', onOk);
|
||||
altBtn.addEventListener('click', onAlt);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
overlay.addEventListener('click', onBackdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Odysseus Modal Controls</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #101114;
|
||||
--panel: #181a1f;
|
||||
--panel-2: #20232a;
|
||||
--fg: #f4f4f5;
|
||||
--muted: rgba(244,244,245,.58);
|
||||
--border: rgba(255,255,255,.13);
|
||||
--accent: #7dd3fc;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(125,211,252,.16), transparent 30%),
|
||||
radial-gradient(circle at 88% 16%, rgba(255,255,255,.08), transparent 26%),
|
||||
linear-gradient(145deg, #0d0e11, #17191f 55%, #0f1013);
|
||||
color: var(--fg);
|
||||
padding: 32px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
font-weight: 720;
|
||||
}
|
||||
p {
|
||||
margin: -8px 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--panel) 86%, transparent);
|
||||
box-shadow: 0 18px 48px rgba(0,0,0,.32);
|
||||
overflow: hidden;
|
||||
}
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 680;
|
||||
}
|
||||
.modal {
|
||||
margin: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
background: color-mix(in srgb, var(--panel-2) 82%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 10px 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, white 4%, transparent);
|
||||
}
|
||||
.title {
|
||||
margin-right: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 680;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.body {
|
||||
height: 84px;
|
||||
padding: 12px 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ctrl {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ctrl::before,
|
||||
.ctrl.close::after {
|
||||
content: "";
|
||||
display: block;
|
||||
background: currentColor;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.ctrl.min::before { width: 9px; height: 1.8px; }
|
||||
.ctrl.close::before,
|
||||
.ctrl.close::after {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 1.7px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
}
|
||||
.ctrl.close::before { transform: translate(-50%, -50%) rotate(45deg); }
|
||||
.ctrl.close::after { transform: translate(-50%, -50%) rotate(-45deg); }
|
||||
|
||||
.v1 .ctrl {
|
||||
background: rgba(255,255,255,.055);
|
||||
border: 1px solid rgba(255,255,255,.13);
|
||||
color: rgba(244,244,245,.68);
|
||||
}
|
||||
.v1 .ctrl:hover {
|
||||
background: rgba(255,255,255,.12);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.v2 .ctrl {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: rgba(255,255,255,.07);
|
||||
color: rgba(244,244,245,.48);
|
||||
}
|
||||
.v2 .ctrl:hover { color: rgba(244,244,245,.92); }
|
||||
.v2 .ctrl.close:hover { background: #ff5f57; color: rgba(40,15,14,.72); }
|
||||
.v2 .ctrl.min:hover { background: #febc2e; color: rgba(67,44,0,.72); }
|
||||
|
||||
.v3 .controls {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.045);
|
||||
}
|
||||
.v3 .ctrl {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: rgba(244,244,245,.62);
|
||||
}
|
||||
.v3 .ctrl:hover {
|
||||
background: rgba(255,255,255,.11);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.v4 .ctrl {
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
background: transparent;
|
||||
color: rgba(244,244,245,.48);
|
||||
}
|
||||
.v4 .ctrl:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.v5 .ctrl {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,.055));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,.13),
|
||||
0 1px 2px rgba(0,0,0,.25);
|
||||
color: rgba(244,244,245,.62);
|
||||
}
|
||||
.v5 .ctrl:hover {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,.22),
|
||||
0 2px 5px rgba(0,0,0,.24);
|
||||
color: var(--fg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<h1>Modal control variants</h1>
|
||||
<p>Pick the one that feels most Odysseus/Apple-sleek, then I’ll apply that globally.</p>
|
||||
<section class="grid">
|
||||
<div class="card v1">
|
||||
<div class="label">A · soft bordered circles</div>
|
||||
<div class="modal">
|
||||
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
|
||||
<div class="body">Closest to current app styling, just cleaner and softer.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card v2">
|
||||
<div class="label">B · macOS traffic-light hover</div>
|
||||
<div class="modal">
|
||||
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
|
||||
<div class="body">Neutral until hover, then yellow/red like macOS window controls.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card v3">
|
||||
<div class="label">C · glass capsule group</div>
|
||||
<div class="modal">
|
||||
<div class="header"><div class="title">Theme</div><span class="controls"><button class="ctrl min"></button><button class="ctrl close"></button></span></div>
|
||||
<div class="body">Buttons read as one small Apple-like control cluster.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card v4">
|
||||
<div class="label">D · ghost controls</div>
|
||||
<div class="modal">
|
||||
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
|
||||
<div class="body">Most minimal. Only shows the round hit area on hover.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card v5">
|
||||
<div class="label">E · subtle liquid glass</div>
|
||||
<div class="modal">
|
||||
<div class="header"><div class="title">Theme</div><button class="ctrl min"></button><button class="ctrl close"></button></div>
|
||||
<div class="body">A little more polished/glassy without becoming colorful.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+1215
-536
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v344';
|
||||
const CACHE_NAME = 'odysseus-v376-settings-title-icons';
|
||||
|
||||
// Core shell precached on install so repeat opens are instant without any
|
||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Odysseus Wave Variants</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #282c34;
|
||||
--panel: #22262e;
|
||||
--line: rgba(255,255,255,.08);
|
||||
--text: #d7dce5;
|
||||
--muted: #8a93a3;
|
||||
--accent: #e06c75;
|
||||
--water: #9cdef2;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 50% 18%, rgba(224,108,117,.10), transparent 34%), var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px;
|
||||
}
|
||||
main {
|
||||
width: min(1040px, 100%);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 22px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
min-height: 178px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, black);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tag {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px 10px;
|
||||
min-height: 108px;
|
||||
}
|
||||
.ascii {
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--accent) 78%, white);
|
||||
font: 600 12px/1.05 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
white-space: pre;
|
||||
text-align: center;
|
||||
opacity: .86;
|
||||
transform: translateY(var(--bob, 0));
|
||||
transition: transform .18s ease;
|
||||
text-shadow: 0 0 18px rgba(224,108,117,.12);
|
||||
}
|
||||
.wave {
|
||||
color: color-mix(in srgb, var(--water) 62%, var(--accent));
|
||||
font-weight: 700;
|
||||
opacity: .72;
|
||||
}
|
||||
.ride-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.ride-dot {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
transform: translate(-50%, var(--ride-y, 0));
|
||||
color: color-mix(in srgb, var(--water) 62%, var(--accent));
|
||||
font-size: 1.24em;
|
||||
line-height: 1;
|
||||
}
|
||||
.note {
|
||||
padding: 9px 12px 11px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
min-height: 42px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.splash {
|
||||
margin-top: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #282c34;
|
||||
height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.splash .ascii {
|
||||
font-size: 13px;
|
||||
opacity: .72;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Odysseus Loader Wave</h1>
|
||||
<p>Ship-on-wave variants for the app splash. Pick the one that feels most like Odysseus sailing instead of just a generic loading wave.</p>
|
||||
<section class="grid" aria-label="Wave variants">
|
||||
<article class="card" data-variant="A">
|
||||
<header><span><span class="tag">A</span> dot boat</span><span>minimal</span></header>
|
||||
<div class="stage"><pre class="ascii" data-ride="dot"></pre></div>
|
||||
<div class="note">Closest to the current loader. The ship is just a small point riding above the wave.</div>
|
||||
</article>
|
||||
<article class="card" data-variant="B">
|
||||
<header><span><span class="tag">B</span> hollow dot</span><span>minimal</span></header>
|
||||
<div class="stage"><pre class="ascii" data-template=" ○
|
||||
{wave}"></pre></div>
|
||||
<div class="note">A little more visible than the dot, but still abstract and calm.</div>
|
||||
</article>
|
||||
<article class="card" data-variant="C">
|
||||
<header><span><span class="tag">C</span> mast dot</span><span>tiny</span></header>
|
||||
<div class="stage"><pre class="ascii" data-template=" |
|
||||
•
|
||||
{wave}"></pre></div>
|
||||
<div class="note">Still tiny, but reads a bit more like a sail/ship than a random dot.</div>
|
||||
</article>
|
||||
<article class="card" data-variant="D">
|
||||
<header><span><span class="tag">D</span> tiny sail</span><span>balanced</span></header>
|
||||
<div class="stage"><pre class="ascii" data-template=" /\
|
||||
•
|
||||
{wave}"></pre></div>
|
||||
<div class="note">Probably the best compromise: a hint of sail, but only two marks above the wave.</div>
|
||||
</article>
|
||||
<article class="card" data-variant="E">
|
||||
<header><span><span class="tag">E</span> tiny hull</span><span>ascii</span></header>
|
||||
<div class="stage"><pre class="ascii" data-template=" /\
|
||||
\_/
|
||||
{wave}"></pre></div>
|
||||
<div class="note">The smallest actual boat shape. More readable, less subtle.</div>
|
||||
</article>
|
||||
<article class="card" data-variant="F">
|
||||
<header><span><span class="tag">F</span> no boat</span><span>baseline</span></header>
|
||||
<div class="stage"><pre class="ascii" data-template="{wave}"></pre></div>
|
||||
<div class="note">The current direction: pure wave, no Odysseus ship marker.</div>
|
||||
</article>
|
||||
</section>
|
||||
<section class="splash" aria-label="Splash scale preview">
|
||||
<pre class="ascii" id="hero-preview" data-ride="dot"></pre>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const waves = [
|
||||
'▁▂▃',
|
||||
'▂▃▄',
|
||||
'▃▄▅',
|
||||
'▄▅▆',
|
||||
'▅▆▅',
|
||||
'▆▅▄',
|
||||
'▅▄▃',
|
||||
'▄▃▂',
|
||||
'▃▂▁'
|
||||
];
|
||||
const rideFrames = [
|
||||
{ wave: '▁▂▃', y: 9 },
|
||||
{ wave: '▂▃▄', y: 7 },
|
||||
{ wave: '▃▄▅', y: 5 },
|
||||
{ wave: '▄▅▆', y: 3 },
|
||||
{ wave: '▅▆▅', y: 1 },
|
||||
{ wave: '▆▅▄', y: 3 },
|
||||
{ wave: '▅▄▃', y: 5 },
|
||||
{ wave: '▄▃▂', y: 7 },
|
||||
{ wave: '▃▂▁', y: 9 }
|
||||
];
|
||||
const render = (el, wave, bob, phase) => {
|
||||
if (el.dataset.ride === 'dot') {
|
||||
const ride = rideFrames[phase % rideFrames.length];
|
||||
el.innerHTML = `<span class="ride-wrap" style="--ride-y:${ride.y}px"><span class="ride-dot">•</span><span class="wave">${ride.wave}</span></span>`;
|
||||
el.style.setProperty('--bob', '0px');
|
||||
return;
|
||||
}
|
||||
const template = el.dataset.template || '{wave}';
|
||||
el.innerHTML = template
|
||||
.replace('{wave}', `<span class="wave">${wave}</span>`)
|
||||
.replaceAll('\n', '<br>');
|
||||
el.style.setProperty('--bob', `${bob}px`);
|
||||
};
|
||||
let i = 0;
|
||||
setInterval(() => {
|
||||
const wave = waves[i % waves.length];
|
||||
const bob = i % 4 === 0 ? -1 : i % 4 === 2 ? 1 : 0;
|
||||
document.querySelectorAll('.ascii').forEach(el => render(el, wave, bob, i));
|
||||
i += 1;
|
||||
}, 170);
|
||||
document.querySelectorAll('.ascii').forEach(el => render(el, waves[0], 0, 0));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,281 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Odysseus Whirlpool Variants</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #101216;
|
||||
--panel: #171a20;
|
||||
--fg: #e8edf2;
|
||||
--muted: #8c949e;
|
||||
--accent: #9cdef2;
|
||||
--border: #303946;
|
||||
--red: #e78284;
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.wrap {
|
||||
width: min(980px, calc(100vw - 28px));
|
||||
margin: 0 auto;
|
||||
padding: 22px 0 34px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.hint {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
input[type="range"] { width: 96px; accent-color: var(--accent); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
min-height: 172px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.name { font-weight: 650; }
|
||||
.desc { color: var(--muted); }
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: 58px 1fr;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 86px;
|
||||
}
|
||||
canvas {
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
display: block;
|
||||
image-rendering: auto;
|
||||
}
|
||||
.sample-lines {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--fg) 80%, transparent);
|
||||
}
|
||||
.line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.footer {
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
header { align-items: flex-start; flex-direction: column; }
|
||||
.controls { justify-content: flex-start; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<header>
|
||||
<div>
|
||||
<h1>Whirlpool Loop Variants</h1>
|
||||
<p class="hint">Tune the loading whirlpool before replacing the app spinner.</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>Size <input id="size" type="range" min="14" max="42" value="24"></label>
|
||||
<label>Speed <input id="speed" type="range" min="650" max="1700" value="1180"></label>
|
||||
<label>Turns <input id="turns" type="range" min="18" max="38" value="27"></label>
|
||||
</div>
|
||||
</header>
|
||||
<section class="grid" id="grid"></section>
|
||||
<div class="footer">Pick a letter. A is current-ish; B/C/D are smoother loop candidates.</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const variants = [
|
||||
{ id: 'A', name: 'Current', desc: 'fixed spiral rotation, visible head loop', mode: 'current' },
|
||||
{ id: 'B', name: 'Soft Tail', desc: 'same spiral, head fades through loop', mode: 'softTail' },
|
||||
{ id: 'C', name: 'Breathing', desc: 'subtle radius pulse hides reset', mode: 'breathing' },
|
||||
{ id: 'D', name: 'Continuous Flow', desc: 'moving dash window, no fixed head snap', mode: 'flow' },
|
||||
];
|
||||
const grid = document.getElementById('grid');
|
||||
const sizeInput = document.getElementById('size');
|
||||
const speedInput = document.getElementById('speed');
|
||||
const turnsInput = document.getElementById('turns');
|
||||
const canvases = [];
|
||||
|
||||
function card(v) {
|
||||
const el = document.createElement('article');
|
||||
el.className = 'card';
|
||||
el.innerHTML = `
|
||||
<div class="card-head">
|
||||
<div><span class="name">${v.id}. ${v.name}</span></div>
|
||||
<div class="desc">${v.desc}</div>
|
||||
</div>
|
||||
<div class="stage">
|
||||
<canvas width="56" height="56" data-mode="${v.mode}"></canvas>
|
||||
<div class="sample-lines">
|
||||
<div class="line"><span class="mini">Loading inbox</span></div>
|
||||
<div class="line"><span class="mini">Scanning recent INBOX</span></div>
|
||||
<div class="line"><span class="mini">Unsubscribing 3/8</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
return el;
|
||||
}
|
||||
|
||||
for (const v of variants) {
|
||||
const node = card(v);
|
||||
grid.appendChild(node);
|
||||
canvases.push(node.querySelector('canvas'));
|
||||
}
|
||||
|
||||
let started = performance.now();
|
||||
function getCss(name, fallback) {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
function spiralPoint(frac, opts) {
|
||||
const eased = Math.pow(frac, opts.ease);
|
||||
const r = opts.maxR * eased * opts.pulse;
|
||||
const angle = frac * opts.turns * Math.PI * 2 + opts.rot;
|
||||
return { x: opts.cx + Math.cos(angle) * r, y: opts.cy + Math.sin(angle) * r };
|
||||
}
|
||||
function draw(canvas, mode, time) {
|
||||
const cssSize = Number(sizeInput.value);
|
||||
canvas.style.setProperty('--size', cssSize + 'px');
|
||||
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
||||
const px = Math.round(cssSize * dpr);
|
||||
if (canvas.width !== px || canvas.height !== px) {
|
||||
canvas.width = px;
|
||||
canvas.height = px;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
const maxR = Math.min(W, H) / 2 - 2 * dpr;
|
||||
const lw = Math.max(1.35 * dpr, cssSize > 30 ? 2.6 * dpr : 1.8 * dpr);
|
||||
const loopMs = Number(speedInput.value);
|
||||
const loop = ((time - started) % loopMs) / loopMs;
|
||||
const rot = loop * Math.PI * 2;
|
||||
const turns = Number(turnsInput.value) / 10;
|
||||
const fg = getCss('--accent', '#9cdef2');
|
||||
const track = getCss('--border', '#303946');
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, maxR - lw / 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = track;
|
||||
ctx.globalAlpha = 0.28;
|
||||
ctx.lineWidth = lw;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
const steps = 110;
|
||||
const pulse = mode === 'breathing' ? 0.94 + 0.06 * Math.sin(loop * Math.PI * 2) : 1;
|
||||
const opts = { cx, cy, maxR, turns, rot, pulse, ease: mode === 'flow' ? 0.76 : 0.82 };
|
||||
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const a = (i - 1) / steps;
|
||||
const b = i / steps;
|
||||
let alpha;
|
||||
let widthMul;
|
||||
if (mode === 'flow') {
|
||||
const phase = (b + loop) % 1;
|
||||
alpha = 0.1 + Math.pow(1 - Math.abs(phase - 0.72) / 0.72, 2.2) * 0.74;
|
||||
widthMul = 0.58 + alpha * 0.38;
|
||||
} else if (mode === 'softTail') {
|
||||
const headFade = 0.62 + 0.38 * Math.sin(loop * Math.PI * 2 - Math.PI / 2) ** 2;
|
||||
alpha = (0.1 + Math.pow(b, 1.85) * 0.72) * (b > 0.88 ? headFade : 1);
|
||||
widthMul = 0.52 + b * 0.34;
|
||||
} else {
|
||||
alpha = 0.12 + Math.pow(b, 1.8) * 0.72;
|
||||
widthMul = 0.52 + b * 0.32;
|
||||
}
|
||||
const p0 = spiralPoint(a, opts);
|
||||
const p1 = spiralPoint(b, opts);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
ctx.lineTo(p1.x, p1.y);
|
||||
ctx.strokeStyle = fg;
|
||||
ctx.lineWidth = lw * widthMul;
|
||||
ctx.globalAlpha = Math.max(0.04, Math.min(0.9, alpha));
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (mode !== 'flow') {
|
||||
const head = spiralPoint(1, opts);
|
||||
const headAlpha = mode === 'softTail'
|
||||
? 0.55 + 0.35 * Math.sin(loop * Math.PI * 2 - Math.PI / 2) ** 2
|
||||
: 0.9;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, Math.max(1.2 * dpr, lw * 0.48), 0, Math.PI * 2);
|
||||
ctx.fillStyle = fg;
|
||||
ctx.globalAlpha = headAlpha;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
for (const canvas of canvases) draw(canvas, canvas.dataset.mode, now);
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
// swift-tools-version: 6.2
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "odysseus-mlx-image-bridge",
|
||||
platforms: [.macOS(.v26)],
|
||||
products: [
|
||||
.executable(name: "odysseus-mlx-inpaint", targets: ["OdysseusMLXInpaint"]),
|
||||
.executable(name: "odysseus-mlx-colorize", targets: ["OdysseusMLXColorize"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/xocialize/mlx-lama-swift", branch: "main"),
|
||||
.package(url: "https://github.com/xocialize/mlx-ddcolor-swift", branch: "main"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "OdysseusMLXInpaint",
|
||||
dependencies: [
|
||||
.product(name: "LaMa", package: "mlx-lama-swift"),
|
||||
.product(name: "MIGAN", package: "mlx-lama-swift"),
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "OdysseusMLXColorize",
|
||||
dependencies: [
|
||||
.product(name: "DDColor", package: "mlx-ddcolor-swift"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import MLX
|
||||
import DDColor
|
||||
|
||||
struct Args {
|
||||
var model = ""
|
||||
var image = ""
|
||||
var output = ""
|
||||
var tier = ""
|
||||
}
|
||||
|
||||
func value(after flag: String, in args: [String]) -> String? {
|
||||
guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
|
||||
return args[i + 1]
|
||||
}
|
||||
|
||||
func parseArgs() throws -> Args {
|
||||
let argv = Array(CommandLine.arguments.dropFirst())
|
||||
var out = Args()
|
||||
out.model = value(after: "--model", in: argv) ?? ""
|
||||
out.image = value(after: "--image", in: argv) ?? ""
|
||||
out.output = value(after: "--output", in: argv) ?? ""
|
||||
out.tier = value(after: "--tier", in: argv) ?? ""
|
||||
guard !out.model.isEmpty, !out.image.isEmpty, !out.output.isEmpty else {
|
||||
throw BridgeError.usage("usage: odysseus-mlx-colorize --model weights.safetensors --image input.png --output output.png [--tier tiny|large]")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeCGImage(_ path: String) throws -> CGImage {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||
let cg = CGImageSourceCreateImageAtIndex(src, 0, nil) else {
|
||||
throw BridgeError.decode(path)
|
||||
}
|
||||
return cg
|
||||
}
|
||||
|
||||
func encodePNG(_ image: CGImage, _ path: String) throws {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
|
||||
throw BridgeError.encode(path)
|
||||
}
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
guard CGImageDestinationFinalize(dest) else { throw BridgeError.encode(path) }
|
||||
}
|
||||
|
||||
enum BridgeError: Error, CustomStringConvertible {
|
||||
case usage(String)
|
||||
case decode(String)
|
||||
case encode(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .usage(let s): return s
|
||||
case .decode(let p): return "failed to decode image: \(p)"
|
||||
case .encode(let p): return "failed to write PNG: \(p)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let args = try parseArgs()
|
||||
let image = try decodeCGImage(args.image)
|
||||
let text = (args.tier + " " + args.model).lowercased()
|
||||
let tier: DDColorTier = text.contains("tiny") ? .tiny : .large
|
||||
let colorizer = try DDColorColorizer.fromPretrained(
|
||||
args.model,
|
||||
config: DDColorConfig(tier: tier),
|
||||
dtype: .float16
|
||||
)
|
||||
let output = colorizer(image)
|
||||
try encodePNG(output, args.output)
|
||||
} catch {
|
||||
fputs("\(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import MLX
|
||||
import LaMa
|
||||
import MIGAN
|
||||
|
||||
struct Args {
|
||||
var model = ""
|
||||
var image = ""
|
||||
var mask = ""
|
||||
var output = ""
|
||||
var mode = ""
|
||||
}
|
||||
|
||||
func value(after flag: String, in args: [String]) -> String? {
|
||||
guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
|
||||
return args[i + 1]
|
||||
}
|
||||
|
||||
func parseArgs() throws -> Args {
|
||||
let argv = Array(CommandLine.arguments.dropFirst())
|
||||
var out = Args()
|
||||
out.model = value(after: "--model", in: argv) ?? ""
|
||||
out.image = value(after: "--image", in: argv) ?? ""
|
||||
out.mask = value(after: "--mask", in: argv) ?? ""
|
||||
out.output = value(after: "--output", in: argv) ?? ""
|
||||
out.mode = value(after: "--mode", in: argv) ?? ""
|
||||
guard !out.model.isEmpty, !out.image.isEmpty, !out.mask.isEmpty, !out.output.isEmpty else {
|
||||
throw BridgeError.usage("usage: odysseus-mlx-inpaint --model weights.safetensors --image input.png --mask mask.png --output output.png [--mode best|fast]")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeCGImage(_ path: String) throws -> CGImage {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||
let cg = CGImageSourceCreateImageAtIndex(src, 0, nil) else {
|
||||
throw BridgeError.decode(path)
|
||||
}
|
||||
return cg
|
||||
}
|
||||
|
||||
func encodePNG(_ image: CGImage, _ path: String) throws {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
|
||||
throw BridgeError.encode(path)
|
||||
}
|
||||
CGImageDestinationAddImage(dest, image, nil)
|
||||
guard CGImageDestinationFinalize(dest) else { throw BridgeError.encode(path) }
|
||||
}
|
||||
|
||||
enum BridgeError: Error, CustomStringConvertible {
|
||||
case usage(String)
|
||||
case decode(String)
|
||||
case encode(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .usage(let s): return s
|
||||
case .decode(let p): return "failed to decode image: \(p)"
|
||||
case .encode(let p): return "failed to write PNG: \(p)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let args = try parseArgs()
|
||||
let source = try decodeCGImage(args.image)
|
||||
let mask = try decodeCGImage(args.mask)
|
||||
let lower = args.model.lowercased()
|
||||
let mode = args.mode.lowercased()
|
||||
let output: CGImage
|
||||
|
||||
if lower.contains("mi-gan") || lower.contains("migan") || mode == "fast" {
|
||||
let resolution = lower.contains("512") ? 512 : 256
|
||||
let inpainter = try MIGANInpainter.fromPretrained(args.model, resolution: resolution, dtype: .float16)
|
||||
output = inpainter(source, mask: mask)
|
||||
} else {
|
||||
let inpainter = try LaMaInpainter.fromPretrained(args.model, dtype: .bfloat16)
|
||||
output = inpainter(source, mask: mask)
|
||||
}
|
||||
try encodePNG(output, args.output)
|
||||
} catch {
|
||||
fputs("\(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
@@ -56,6 +56,20 @@ def test_explicit_web_search_promotes_to_agent():
|
||||
assert classify_tool_intent("use web search and find a recipe").category == "web"
|
||||
|
||||
|
||||
def test_workspace_agent_requests_promote_to_shell_workspace():
|
||||
prompts = [
|
||||
"fix the bug in this repo",
|
||||
"run the tests for this project",
|
||||
"debug the server logs",
|
||||
"run terminal-bench on this task",
|
||||
"inspect the traceback and patch the code",
|
||||
]
|
||||
for prompt in prompts:
|
||||
intent = classify_tool_intent(prompt)
|
||||
assert intent.needs_tools
|
||||
assert intent.category == "workspace"
|
||||
|
||||
|
||||
def test_explanatory_calendar_questions_stay_plain_chat():
|
||||
assert not message_needs_tools("How do I add an entry to my calendar?")
|
||||
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
|
||||
|
||||
@@ -36,6 +36,66 @@ def test_npx_package_from_args_prefers_package_after_y_flag(monkeypatch):
|
||||
) == "@playwright/mcp@latest"
|
||||
|
||||
|
||||
def test_browser_mcp_cache_requirement_is_opt_in(monkeypatch):
|
||||
monkeypatch.delenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", raising=False)
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is False
|
||||
|
||||
|
||||
def test_browser_mcp_cache_requirement_can_be_enabled(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "1")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
assert builtin_mcp.BROWSER_MCP_REQUIRE_CACHE is True
|
||||
|
||||
|
||||
def test_browser_mcp_args_use_configured_browser_executable(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "/usr/bin/chromium" in args
|
||||
assert "--isolated" in args
|
||||
assert "--no-sandbox" in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_can_use_persistent_profile_when_requested(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_ISOLATED", "0")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "--isolated" not in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_respect_explicit_user_data_dir(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args([
|
||||
"-y", "@playwright/mcp@latest", "--headless", "--user-data-dir", "/tmp/profile",
|
||||
])
|
||||
|
||||
assert "--user-data-dir" in args
|
||||
assert "--isolated" not in args
|
||||
|
||||
|
||||
def test_browser_mcp_args_can_keep_sandbox(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_EXECUTABLE", "/usr/bin/chromium")
|
||||
monkeypatch.setenv("ODYSSEUS_BROWSER_NO_SANDBOX", "0")
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
args = builtin_mcp._browser_mcp_args(["-y", "@playwright/mcp@latest", "--headless"])
|
||||
|
||||
assert "--executable-path" in args
|
||||
assert "--no-sandbox" not in args
|
||||
|
||||
|
||||
def test_npx_cache_check_detects_scoped_package_in_npx_cache(monkeypatch, tmp_path):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
package_json = (
|
||||
|
||||
@@ -80,7 +80,7 @@ async def test_consolidate_memory_empty_owner_treats_each_owner_separately(monke
|
||||
message, ok = await action_consolidate_memory("")
|
||||
|
||||
assert ok is True
|
||||
assert "removed 1" in message
|
||||
assert "removed 1" in message.lower()
|
||||
assert len(prompts) == 2
|
||||
saved = {m["id"]: m for m in _read_memories(data_dir)}
|
||||
assert set(saved) == {"alice-long", "alice-short", "bob-keep"}
|
||||
@@ -114,3 +114,46 @@ async def test_consolidate_memory_specific_owner_does_not_absorb_ownerless_rows(
|
||||
assert set(saved) == {"alice-1", "legacy", "bob-1"}
|
||||
assert "owner" not in saved["legacy"]
|
||||
assert saved["bob-1"]["owner"] == "bob"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidate_memory_removes_near_duplicates_before_ai(monkeypatch, tmp_path):
|
||||
from src import constants
|
||||
from src import llm_core
|
||||
from src import task_endpoint
|
||||
action_consolidate_memory = _import_consolidate_action()
|
||||
|
||||
data_dir = _write_memories(
|
||||
tmp_path,
|
||||
[
|
||||
{"id": "a", "owner": "alice", "text": "User prefers bullet points when explaining.", "category": "preference"},
|
||||
{"id": "b", "owner": "alice", "text": "User prefers bulletpoints when explaining", "category": "preference", "pinned": True},
|
||||
{"id": "c", "owner": "alice", "text": "User likes local models.", "category": "preference"},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(constants, "DATA_DIR", str(data_dir))
|
||||
monkeypatch.setattr(
|
||||
task_endpoint,
|
||||
"resolve_task_candidates",
|
||||
lambda *args, **kwargs: [("http://llm", "model", {})],
|
||||
)
|
||||
|
||||
async def fake_llm_call_async(_candidates, **kwargs):
|
||||
items = json.loads(kwargs["messages"][0]["content"].split("MEMORIES:\n", 1)[1])
|
||||
return json.dumps({
|
||||
"keep": [
|
||||
{"id": item["id"], "text": item["text"], "category": item["category"]}
|
||||
for item in items
|
||||
],
|
||||
"drop": [],
|
||||
})
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async_with_fallback", fake_llm_call_async)
|
||||
|
||||
message, ok = await action_consolidate_memory("alice")
|
||||
|
||||
assert ok is True
|
||||
assert "removed 1" in message.lower()
|
||||
saved = {m["id"]: m for m in _read_memories(data_dir)}
|
||||
assert set(saved) == {"b", "c"}
|
||||
assert saved["b"]["pinned"] is True
|
||||
|
||||
@@ -342,10 +342,10 @@ def test_clean_thinking_for_save_extracts_thought_tag():
|
||||
assert metadata["thinking"] == "internal reasoning"
|
||||
|
||||
|
||||
def test_save_assistant_response_preserves_actual_and_requested_model():
|
||||
def test_save_assistant_response_incognito_does_not_mutate_session_history():
|
||||
sess = _FakeSession("selected-model")
|
||||
|
||||
save_assistant_response(
|
||||
saved_id = save_assistant_response(
|
||||
sess,
|
||||
session_manager=None,
|
||||
session_id="s1",
|
||||
@@ -354,8 +354,24 @@ def test_save_assistant_response_preserves_actual_and_requested_model():
|
||||
incognito=True,
|
||||
)
|
||||
|
||||
assert sess.history[-1].metadata["requested_model"] == "selected-model"
|
||||
assert sess.history[-1].metadata["model"] == "actual-model"
|
||||
assert saved_id is None
|
||||
assert sess.history == []
|
||||
|
||||
|
||||
def test_add_user_message_incognito_does_not_mutate_session_history():
|
||||
sess = _FakeSession("selected-model")
|
||||
chat_handler = SimpleNamespace(update_session_name_if_needed=lambda *_args, **_kwargs: None)
|
||||
preprocessed = PreprocessedMessage(
|
||||
enhanced_message="secret",
|
||||
user_content="secret",
|
||||
text_for_context="secret",
|
||||
youtube_transcripts=[],
|
||||
attachment_meta=[],
|
||||
)
|
||||
|
||||
chat_helpers.add_user_message(sess, chat_handler, preprocessed, incognito=True)
|
||||
|
||||
assert sess.history == []
|
||||
|
||||
|
||||
class _SpinMsg:
|
||||
|
||||
@@ -60,6 +60,15 @@ def test_image_model_prefix_routes_to_image_generation_without_endpoint_lookup(m
|
||||
assert chat_routes._is_image_generation_session(_session(model="dall-e-3"))
|
||||
|
||||
|
||||
def test_namespaced_gpt_image_model_routes_to_image_generation_without_endpoint_lookup(monkeypatch):
|
||||
def fail_if_called():
|
||||
raise AssertionError("provider-prefixed image models should not need a DB lookup")
|
||||
|
||||
monkeypatch.setattr(chat_routes, "SessionLocal", fail_if_called)
|
||||
|
||||
assert chat_routes._is_image_generation_session(_session(model="openai/gpt-5-image"))
|
||||
|
||||
|
||||
def test_image_endpoint_does_not_catch_text_model_on_different_path(monkeypatch):
|
||||
db = _FakeDb([
|
||||
_endpoint("http://localhost:11434/v1/images", models=["sdxl-local"]),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user