mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-04 20:22:21 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac59573b8 | ||
|
|
c21980f5a6 | ||
|
|
7fdc97880a | ||
|
|
9974841c49 | ||
|
|
631c05603c | ||
|
|
f814018098 | ||
|
|
d47d85d8a4 | ||
|
|
77964b0025 | ||
|
|
682662d36f | ||
|
|
b7ab0a37c1 | ||
|
|
3efbecd50a | ||
|
|
06c1e71fa0 | ||
|
|
1e34f938cc | ||
|
|
596e1421e4 | ||
|
|
cad14f81b9 | ||
|
|
8532f4606b | ||
|
|
5d4e8d57bb | ||
|
|
ed531cfc61 | ||
|
|
f00ae350cd | ||
|
|
c1f959ae61 |
+39
-1
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
||||
# backwards for this sentinel.
|
||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||
|
||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH
|
||||
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
@@ -62,6 +62,24 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
||||
|
||||
|
||||
def username_error(username: str | None) -> Optional[str]:
|
||||
"""Why a new username is not acceptable, or None when it is.
|
||||
|
||||
Admin routes carry the name as a URL path segment, where "/" never
|
||||
reaches the handler, so such an account could not be managed afterwards.
|
||||
"""
|
||||
name = (username or "").strip()
|
||||
if not name:
|
||||
return "Username is required"
|
||||
if len(name) > USERNAME_MAX_LENGTH:
|
||||
return f"Username must be at most {USERNAME_MAX_LENGTH} characters"
|
||||
if "/" in name or "\\" in name or any(not c.isprintable() for c in name):
|
||||
return "Username must not contain slashes or control characters"
|
||||
if name in (".", ".."):
|
||||
return "Username is not allowed"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
||||
"""Return a normalized username only when it exists in the auth user map."""
|
||||
key = str(username or "").strip().lower()
|
||||
@@ -396,6 +414,15 @@ class AuthManager:
|
||||
current = self.get_privileges(username)
|
||||
for k, v in privileges.items():
|
||||
if k in DEFAULT_PRIVILEGES:
|
||||
default = DEFAULT_PRIVILEGES[k]
|
||||
if isinstance(default, bool):
|
||||
ok = isinstance(v, bool)
|
||||
elif isinstance(default, int):
|
||||
ok = isinstance(v, int) and not isinstance(v, bool)
|
||||
else:
|
||||
ok = isinstance(v, list)
|
||||
if not ok:
|
||||
raise ValueError(f"Invalid value for privilege '{k}'")
|
||||
current[k] = v
|
||||
self._config["users"][username]["privileges"] = current
|
||||
self._save()
|
||||
@@ -436,6 +463,17 @@ class AuthManager:
|
||||
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
||||
if admin_count <= 1:
|
||||
return SetAdminResult.LAST_ADMIN
|
||||
# Token creation is admin-only and a demoted user can neither
|
||||
# list nor revoke their tokens, so purge them with the role.
|
||||
# Done before the flag flips: if the token store fails the
|
||||
# exception propagates and the user is still an admin.
|
||||
from core.database import get_db_session, ApiToken
|
||||
with get_db_session() as db:
|
||||
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
||||
if removed_tokens:
|
||||
logger.info(
|
||||
f"Revoked {removed_tokens} API token(s) owned by demoted admin '{username}'"
|
||||
)
|
||||
# Write order matters for lock-free readers: get_privileges()
|
||||
# reads without _config_lock and trusts is_admin, so the admin
|
||||
# flag must be flipped while the stored map is safe to expose —
|
||||
|
||||
+12
-15
@@ -33,6 +33,7 @@ from mcp.types import Tool, TextContent
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.email_mime import decode_text_part as _decode_text_part
|
||||
server = Server("email")
|
||||
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
|
||||
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
|
||||
@@ -923,29 +924,25 @@ def _extract_text(msg):
|
||||
"""Extract plain text body from email message."""
|
||||
if msg.is_multipart():
|
||||
text_parts = []
|
||||
html_parts = []
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/plain" and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
text_parts.append(payload.decode(charset, errors="replace"))
|
||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
raw_html = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||
raw_html = _decode_text_part(part)
|
||||
if raw_html:
|
||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
text_parts.append(text.strip())
|
||||
return "\n".join(text_parts)
|
||||
html_parts.append(text.strip())
|
||||
return "\n".join(text_parts or html_parts)
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
text = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
if msg.get_content_type() == "text/html":
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||
|
||||
+35
-10
@@ -13,7 +13,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL, username_error
|
||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.rate_limiter import RateLimiter
|
||||
@@ -155,8 +155,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(400, "Already configured")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
||||
@@ -175,8 +176,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
||||
@@ -334,8 +336,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Admin only")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
||||
@@ -362,8 +365,16 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
body = await request.json()
|
||||
ok = auth_manager.set_privileges(username, body)
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
try:
|
||||
ok = auth_manager.set_privileges(username, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
if not ok:
|
||||
raise HTTPException(404, "User not found or is admin")
|
||||
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
|
||||
@@ -377,6 +388,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
new_username = (body.username or "").strip().lower()
|
||||
if not new_username:
|
||||
raise HTTPException(400, "Username required")
|
||||
name_error = username_error(new_username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if old_username == new_username:
|
||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||
if old_username not in auth_manager.users:
|
||||
@@ -654,7 +668,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||
try:
|
||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||
finally:
|
||||
# A demotion purges the user's ApiToken rows; dirty the bearer
|
||||
# cache so already-cached tokens stop authenticating.
|
||||
if not body.is_admin:
|
||||
invalidator = getattr(
|
||||
getattr(getattr(request, "app", None), "state", None),
|
||||
"invalidate_token_cache", None,
|
||||
)
|
||||
if callable(invalidator):
|
||||
invalidator()
|
||||
if result is SetAdminResult.USER_NOT_FOUND:
|
||||
raise HTTPException(404, "User not found")
|
||||
if result is SetAdminResult.NOT_AUTHORIZED:
|
||||
|
||||
@@ -728,8 +728,13 @@ def _check_serve_binary(seg: str) -> None:
|
||||
if not tokens:
|
||||
return
|
||||
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
first = next((t for t in tokens if not env_re.match(t)), "")
|
||||
idx = next((i for i, t in enumerate(tokens) if not env_re.match(t)), len(tokens))
|
||||
first = tokens[idx] if idx < len(tokens) else ""
|
||||
base = os.path.basename(first)
|
||||
# The vLLM/SGLang Dependencies recipes install with `uv pip install …
|
||||
# --torch-backend auto`. Allow uv for that subcommand only, not `uv run`.
|
||||
if base == "uv" and tokens[idx + 1:idx + 3] == ["pip", "install"]:
|
||||
return
|
||||
if base not in _SERVE_CMD_ALLOWLIST:
|
||||
raise HTTPException(
|
||||
400,
|
||||
|
||||
@@ -36,6 +36,7 @@ from fastapi import Query, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
from src.email_mime import decode_text_part as _decode_text_part
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.secret_storage import decrypt as _decrypt
|
||||
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
|
||||
@@ -1776,44 +1777,38 @@ def _extract_html(msg):
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/html" and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
return text
|
||||
elif msg.get_content_type() == "text/html":
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(msg):
|
||||
if msg.is_multipart():
|
||||
text_parts = []
|
||||
html_parts = []
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/plain" and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
text_parts.append(payload.decode(charset, errors="replace"))
|
||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
raw_html = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||
raw_html = _decode_text_part(part)
|
||||
if raw_html:
|
||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
text_parts.append(text.strip())
|
||||
return "\n".join(text_parts)
|
||||
html_parts.append(text.strip())
|
||||
return "\n".join(text_parts or html_parts)
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
text = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
if msg.get_content_type() == "text/html":
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||
|
||||
@@ -471,10 +471,16 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
role = body.get("role", "assistant")
|
||||
content = body.get("content", "")
|
||||
if role not in ("user", "assistant"):
|
||||
raise HTTPException(400, "role must be 'user' or 'assistant'")
|
||||
if not content:
|
||||
raise HTTPException(400, "content is required")
|
||||
if not isinstance(content, str):
|
||||
raise HTTPException(400, "content must be a string")
|
||||
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
||||
_reserve_message_uploads(request, content, metadata)
|
||||
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||
|
||||
+12
-4
@@ -27,6 +27,7 @@ from pathlib import Path
|
||||
|
||||
try:
|
||||
from core.database import SessionLocal, Document, DocumentVersion
|
||||
from src.session_search import escape_like_pattern
|
||||
quiet_logs()
|
||||
except ModuleNotFoundError as e:
|
||||
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
||||
@@ -139,10 +140,17 @@ def cmd_export(args):
|
||||
def cmd_search(args):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
like = f"%{args.query}%"
|
||||
rows = db.query(Document).filter(
|
||||
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
|
||||
).order_by(Document.updated_at.desc()).limit(args.limit).all()
|
||||
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||
# exact phrase, so "machine learning" missed docs containing both
|
||||
# words apart. LIKE wildcards are escaped per token.
|
||||
q = db.query(Document)
|
||||
for tok in args.query.split():
|
||||
term = f"%{escape_like_pattern(tok)}%"
|
||||
q = q.filter(
|
||||
Document.title.ilike(term, escape="\\")
|
||||
| Document.current_content.ilike(term, escape="\\")
|
||||
)
|
||||
rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all()
|
||||
emit([_serialize(d) for d in rows], args)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -660,6 +660,8 @@ def _detect_windows():
|
||||
"gpu_vram_gb": d.get("gpu_vram_gb"),
|
||||
"gpu_count": _as_int(d.get("gpu_count"), 0),
|
||||
"backend": d.get("gpu_backend", "cpu_x86"),
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"gpu_error": None,
|
||||
"platform": "windows",
|
||||
@@ -822,12 +824,14 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
if result:
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
# SSH may work while the PowerShell hardware probe still fails.
|
||||
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
@@ -852,6 +856,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
return result
|
||||
available_ram = round(_get_available_ram_gb(), 1)
|
||||
@@ -893,6 +898,9 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
"gpu_count": 0,
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"backend": backend,
|
||||
# Set when nvidia-smi exists but failed (e.g. driver/library
|
||||
# version mismatch) — lets the UI say "GPU driver error" instead
|
||||
@@ -902,6 +910,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
|
||||
@@ -4,7 +4,7 @@ import html
|
||||
import difflib
|
||||
import logging
|
||||
import re
|
||||
from src.constants import MAX_READ_CHARS
|
||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
|
||||
from src.tool_approvals import document_content_digest
|
||||
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||
from src.upload_handler import reserve_upload_references
|
||||
@@ -1175,14 +1175,23 @@ class ManageDocumentTool:
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
offset = min(offset, len(body))
|
||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||
header = f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n"
|
||||
# Reserve the closing fence and the longest possible paging
|
||||
# notice before selecting the page. The common output cap must
|
||||
# never hide content that next_offset claims was delivered.
|
||||
notice = f"\n... (truncated, {len(body)} chars total; next_offset={len(body)})"
|
||||
page_budget = MAX_OUTPUT_CHARS - len(header) - len(notice) - len("\n```")
|
||||
if page_budget < 1:
|
||||
return {"error": "Document title or language is too long to display a read page", "exit_code": 1}
|
||||
preview_limit = min(preview_limit, page_budget)
|
||||
end = min(offset + preview_limit, len(body))
|
||||
truncated = end < len(body)
|
||||
preview = body[offset:end]
|
||||
if truncated:
|
||||
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
|
||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||
return {
|
||||
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
|
||||
"response": f"{header}{preview}\n```",
|
||||
"document": {
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
|
||||
@@ -175,6 +175,7 @@ AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
|
||||
|
||||
# Auth policy
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
USERNAME_MAX_LENGTH = 64
|
||||
|
||||
# Default parameters
|
||||
DEFAULT_TEMPERATURE = 1.0
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Shared MIME text decoding for the web email helpers and MCP server."""
|
||||
|
||||
|
||||
def decode_text_part(part) -> str:
|
||||
payload = part.get_payload(decode=True)
|
||||
if not payload:
|
||||
return ""
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
return payload.decode(charset, errors="replace")
|
||||
except (LookupError, ValueError):
|
||||
# Broken senders can label valid UTF-8 with an unsupported codec.
|
||||
return payload.decode("utf-8", errors="replace")
|
||||
+15
-26
@@ -364,32 +364,32 @@ class MemoryManager:
|
||||
return []
|
||||
|
||||
# Define keyword categories for semantic matching
|
||||
identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"]
|
||||
identity_words = ["name", "called", "identity", "myself"]
|
||||
contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"]
|
||||
preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"]
|
||||
task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"]
|
||||
fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"]
|
||||
|
||||
query_lower = query.lower()
|
||||
query_words = set(re.findall(r'\w+', query_lower))
|
||||
query_tokens = set(tokenize(query_lower))
|
||||
|
||||
# Determine query type based on keywords
|
||||
query_type = None
|
||||
if any(word in query_lower for word in identity_words):
|
||||
if query_words.intersection(identity_words) or re.search(r'\bwho\s+am\s+i\b', query_lower):
|
||||
query_type = "identity"
|
||||
elif any(word in query_lower for word in contact_words):
|
||||
elif query_words.intersection(contact_words):
|
||||
query_type = "contact"
|
||||
elif any(word in query_lower for word in preference_words):
|
||||
elif query_words.intersection(preference_words):
|
||||
query_type = "preference"
|
||||
elif any(word in query_lower for word in task_words):
|
||||
elif query_words.intersection(task_words):
|
||||
query_type = "task"
|
||||
elif any(word in query_lower for word in fact_words):
|
||||
elif query_words.intersection(fact_words):
|
||||
query_type = "fact"
|
||||
|
||||
relevant = []
|
||||
identity_memories = []
|
||||
other_memories = []
|
||||
|
||||
# Separate identity memories from others
|
||||
# Every memory participates in the same ranking and threshold check.
|
||||
# Query type is a scoring hint, not an unconditional insertion path.
|
||||
for memory in memories:
|
||||
memory_text = memory["text"].lower()
|
||||
# Check if this is an identity memory (contains name patterns or identity indicators)
|
||||
@@ -397,22 +397,7 @@ class MemoryManager:
|
||||
re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]),
|
||||
any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"])
|
||||
])
|
||||
if is_identity:
|
||||
identity_memories.append(memory)
|
||||
else:
|
||||
other_memories.append(memory)
|
||||
|
||||
# For identity queries, include all identity memories regardless of similarity
|
||||
if query_type == "identity" and identity_memories:
|
||||
# Give them high scores to ensure they're included first
|
||||
for memory in identity_memories:
|
||||
relevant.append((0.9, memory)) # High score for identity memories in identity queries
|
||||
|
||||
# Process other memories with similarity scoring
|
||||
for memory in other_memories:
|
||||
memory_text = memory["text"].lower()
|
||||
memory_tokens = set(tokenize(memory_text))
|
||||
query_tokens = set(tokenize(query_lower))
|
||||
|
||||
# Calculate base Jaccard similarity
|
||||
if not query_tokens or not memory_tokens:
|
||||
@@ -422,7 +407,11 @@ class MemoryManager:
|
||||
final_score = base_similarity
|
||||
|
||||
# Apply boosts based on semantic matching
|
||||
if query_type == "contact":
|
||||
if query_type == "identity" and is_identity:
|
||||
# Keep explicit name/identity recall useful without outranking
|
||||
# a closer textual match or bypassing the caller's threshold.
|
||||
final_score = max(final_score * 1.3, 0.6)
|
||||
elif query_type == "contact":
|
||||
# Boost memories with contact information
|
||||
has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com",
|
||||
"phone", "number", "address",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Preserve report TeX for markdown.js's existing deferred KaTeX renderer."""
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from markdown.blockprocessors import BlockProcessor
|
||||
from markdown.extensions import Extension
|
||||
from markdown.inlinepatterns import InlineProcessor
|
||||
from markdown.util import AtomicString
|
||||
|
||||
|
||||
# Keep delimiter and currency rules aligned with mathBlocks in markdown.js.
|
||||
_ESCAPED_MATH = r"\\\[([\s\S]*?)\\\]|\\\(([^\n]*?)\\\)"
|
||||
_DOLLAR_MATH = (
|
||||
r"\$\$([\s\S]*?)\$\$|"
|
||||
r"(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)"
|
||||
)
|
||||
|
||||
|
||||
def _placeholder(tex, display):
|
||||
node = ElementTree.Element("span", {
|
||||
"class": "ody-math-pending", "data-display": str(display).lower(),
|
||||
})
|
||||
node.text = AtomicString(tex)
|
||||
return node
|
||||
|
||||
|
||||
class _HtmlContext(HTMLParser):
|
||||
"""Inline HTML is still raw when math runs, including tag attributes."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.protected = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in {"code", "pre", "script", "style", "textarea"}:
|
||||
self.protected.append(tag)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in self.protected:
|
||||
del self.protected[self.protected.index(tag):]
|
||||
|
||||
|
||||
class _InlineMath(InlineProcessor):
|
||||
def handleMatch(self, match, data):
|
||||
prefix = data[:match.start()]
|
||||
if "<" in prefix:
|
||||
context = _HtmlContext()
|
||||
context.feed(prefix)
|
||||
if context.protected or re.match(r"<(?:/?[A-Za-z]|[!?])", context.rawdata.lstrip()):
|
||||
return None, None, None
|
||||
# Backticks run before this processor. Preserve a backslash-escaped
|
||||
# dollar too; math must run before Markdown consumes \( and \[.
|
||||
if prefix.endswith("\\") and (len(prefix) - len(prefix.rstrip("\\"))) % 2:
|
||||
return None, None, None
|
||||
tex = next(group for group in match.groups() if group is not None)
|
||||
display = match.group(1) is not None
|
||||
return _placeholder(tex, display), match.start(), match.end()
|
||||
|
||||
|
||||
class _DisplayMath(BlockProcessor):
|
||||
def test(self, parent, block):
|
||||
return re.match(r"^ {0,3}(\$\$|\\\[)", block) is not None
|
||||
|
||||
def run(self, parent, blocks):
|
||||
opening = re.match(r"^ {0,3}(\$\$|\\\[)", blocks[0])
|
||||
closing = "$$" if opening.group(1) == "$$" else "\\]"
|
||||
source = blocks[0][opening.end():]
|
||||
consumed = 1
|
||||
while closing not in source:
|
||||
if consumed == len(blocks):
|
||||
return False # Leave incomplete input to ordinary Markdown.
|
||||
source += "\n\n" + blocks[consumed]
|
||||
consumed += 1
|
||||
tex, _, tail = source.partition(closing)
|
||||
del blocks[:consumed]
|
||||
if tail.strip():
|
||||
blocks.insert(0, tail)
|
||||
parent.append(_placeholder(tex, True))
|
||||
|
||||
|
||||
class ReportMathExtension(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
# Backticks (190) and indented code (80) retain precedence.
|
||||
md.inlinePatterns.register(_InlineMath(_ESCAPED_MATH, md), "report_math_escaped", 185)
|
||||
# Parse links before dollar math so URL destinations stay literal.
|
||||
md.inlinePatterns.register(_InlineMath(_DOLLAR_MATH, md), "report_math_dollar", 155)
|
||||
md.parser.blockprocessors.register(_DisplayMath(md.parser), "report_math", 79)
|
||||
@@ -2562,6 +2562,16 @@ def _compact_binary_like_output(value: object) -> str:
|
||||
|
||||
def format_tool_result(description: str, result: Dict) -> str:
|
||||
"""Format a tool result into text for feeding back to the LLM."""
|
||||
if (
|
||||
description.startswith("manage_documents:")
|
||||
and isinstance(result.get("document"), dict)
|
||||
and "response" in result
|
||||
):
|
||||
# Document reads already budget their complete page, anchor and
|
||||
# next_offset notice. A heading or a second JSON copy of the content
|
||||
# would exhaust that budget and obscure the continuation again.
|
||||
return _truncate(result["response"])
|
||||
|
||||
parts = [f"### {description}"]
|
||||
|
||||
if "stdout" in result:
|
||||
|
||||
+28
-3
@@ -23,6 +23,7 @@ from typing import Dict, List, Optional, Tuple
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.research_utils import strip_thinking
|
||||
from src.report_math import ReportMathExtension
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import markdown
|
||||
@@ -47,6 +48,7 @@ for _t in ("td", "th"):
|
||||
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("span", set()).add("data-display")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -79,7 +81,7 @@ def _md_to_html(md_text: str) -> str:
|
||||
md_text = _autolink_urls(md_text)
|
||||
result = markdown.markdown(
|
||||
md_text,
|
||||
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html"],
|
||||
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html", ReportMathExtension()],
|
||||
extension_configs={
|
||||
"codehilite": {"css_class": "code", "guess_lang": False},
|
||||
"toc": {"marker": "", "toc_depth": "2-3"},
|
||||
@@ -1021,6 +1023,8 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
|
||||
.content code {{ font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }}
|
||||
.content pre {{ background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }}
|
||||
.content pre code {{ background: none; padding: 0; }}
|
||||
/* Match the chat renderer's handling of wide display equations. */
|
||||
.content .katex-display {{ overflow-x: auto; overflow-y: hidden; }}
|
||||
.content table {{ width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }}
|
||||
.content th {{ text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }}
|
||||
.content td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }}
|
||||
@@ -1434,6 +1438,12 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
// Reuse the chat renderer and vendored assets only for reports with math.
|
||||
if (document.querySelector('.ody-math-pending')) {{
|
||||
import('/static/js/markdown.js').then(function(markdown) {{
|
||||
return markdown.renderMath(document);
|
||||
}}).catch(function(err) {{ console.warn('Report math render error:', err); }});
|
||||
}}
|
||||
// ESC closes the report tab. window.close() works when the tab was
|
||||
// opened via window.open() (which is how the panel launches it). If the
|
||||
// browser blocks self-close (rare — e.g. report opened by direct URL),
|
||||
@@ -2835,11 +2845,23 @@ def generate_visual_report(
|
||||
spare_images = section_pool[_consumed:]
|
||||
|
||||
# Build TOC
|
||||
math_labels = {}
|
||||
if 'ody-math-pending' in report_html:
|
||||
label_soup = BeautifulSoup(report_html, "html.parser")
|
||||
for heading in label_soup.select('h2[id], h3[id]'):
|
||||
if not heading.select_one('.ody-math-pending'):
|
||||
continue
|
||||
for child in list(heading.find_all(True)):
|
||||
if 'ody-math-pending' in child.get('class', []):
|
||||
child['data-display'] = 'false'
|
||||
else:
|
||||
child.unwrap()
|
||||
math_labels[heading['id']] = heading.decode_contents()
|
||||
toc_lines = []
|
||||
for h in headings:
|
||||
depth_class = f"depth-{h['level']}"
|
||||
toc_lines.append(
|
||||
f'<a href="#{h["slug"]}" class="{depth_class}">{html.escape(h["text"])}</a>'
|
||||
f'<a href="#{h["slug"]}" class="{depth_class}">{math_labels.get(h["slug"], html.escape(h["text"]))}</a>'
|
||||
)
|
||||
toc_html = "\n ".join(toc_lines) if toc_lines else ""
|
||||
|
||||
@@ -2848,8 +2870,11 @@ def generate_visual_report(
|
||||
options = []
|
||||
for h in headings:
|
||||
prefix = "\u2014 " if h["level"] == 3 else ""
|
||||
text = h["text"]
|
||||
if h["slug"] in math_labels:
|
||||
text = BeautifulSoup(math_labels[h["slug"]], "html.parser").get_text()
|
||||
options.append(
|
||||
f'<option value="{h["slug"]}">{prefix}{html.escape(h["text"])}</option>'
|
||||
f'<option value="{h["slug"]}">{prefix}{html.escape(text)}</option>'
|
||||
)
|
||||
mobile_toc_html = (
|
||||
'<div class="mobile-section-nav">'
|
||||
|
||||
+13
-3
@@ -561,10 +561,21 @@ function initializeEventListeners() {
|
||||
|
||||
// Export: PDF
|
||||
const exportPdfBtn = el('export-pdf-btn');
|
||||
let preparingPdf = false;
|
||||
if (exportPdfBtn) {
|
||||
exportPdfBtn.addEventListener('click', (e) => {
|
||||
exportPdfBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (preparingPdf) return;
|
||||
preparingPdf = true;
|
||||
try {
|
||||
await sessionModule.prepareHistoryForPrint();
|
||||
} catch (error) {
|
||||
uiModule.showError(error?.message || 'Could not prepare the chat for PDF export.');
|
||||
return;
|
||||
} finally {
|
||||
preparingPdf = false;
|
||||
}
|
||||
const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||
const sessionName = meta ? meta.name : 'Odysseus Chat';
|
||||
const originalTitle = document.title;
|
||||
@@ -1308,8 +1319,7 @@ function initializeEventListeners() {
|
||||
const toolThemeBtn = el('tool-theme-btn');
|
||||
if (toolThemeBtn) {
|
||||
toolThemeBtn.addEventListener('click', () => {
|
||||
const tm = document.getElementById('theme-modal');
|
||||
if (tm) tm.classList.remove('hidden');
|
||||
themeModule.openPopup();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6481,6 +6481,13 @@ body.bg-pattern-ascii-fireflies {
|
||||
display:flex; justify-content:flex-end; gap:8px; padding-top:6px;
|
||||
border-top:1px solid var(--border); margin-top:4px;
|
||||
}
|
||||
/* The global `button { height:32px }` would hold a wrapped label (e.g.
|
||||
"Delete chat and images") to one line's height, so its second line
|
||||
spills out below the button. Let confirm buttons grow to fit; 32px
|
||||
stays the minimum, so single-line buttons look the same. */
|
||||
.styled-confirm-box .confirm-btn {
|
||||
height:auto; min-height:32px;
|
||||
}
|
||||
@media (max-width:768px) {
|
||||
.styled-confirm-box {
|
||||
width: 85vw;
|
||||
@@ -6516,10 +6523,8 @@ body.bg-pattern-ascii-fireflies {
|
||||
}
|
||||
.confirm-btn-secondary { background:var(--bg); color:var(--fg); }
|
||||
.confirm-btn-secondary:hover { background:var(--border); }
|
||||
#styled-confirm-ok {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
#styled-confirm-ok .styled-confirm-btn-label,
|
||||
#styled-confirm-alt .styled-confirm-btn-label,
|
||||
#styled-confirm-cancel .styled-confirm-cancel-label {
|
||||
position:relative;
|
||||
top:2px;
|
||||
|
||||
@@ -1701,6 +1701,7 @@
|
||||
|
||||
/* ── Print / PDF Export ── */
|
||||
@media print {
|
||||
html, body { height: auto !important; overflow: visible !important; }
|
||||
body { background: #fff !important; color: #000 !important; }
|
||||
#sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
|
||||
#welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
|
||||
|
||||
+24
-24
@@ -247,32 +247,32 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20261021confirmalign1">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20261007detailscroll1">
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20261021confirmalign1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
@@ -2472,7 +2472,7 @@
|
||||
</div>
|
||||
|
||||
<div class="admin-toggle-sub" style="margin-top:4px;">
|
||||
Auto-approve turns off the tool approval gate (same control as the
|
||||
Off (YOLO) turns off the tool approval gate (same control as the
|
||||
ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and
|
||||
delete_email then run without a confirmation even after the agent has
|
||||
seen untrusted external content (email, web pages, documents), so a
|
||||
@@ -2709,7 +2709,7 @@
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
|
||||
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
|
||||
<script type="module" src="/static/app.js?v=20261007detailscroll1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/app.js?v=20261021confirmalign1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
|
||||
|
||||
@@ -1762,7 +1762,9 @@ async function _fetchDependencies() {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
const reqBody = {
|
||||
repo_id: `${backend} setup`,
|
||||
// The server validates repo_id as a pip package spec for pip
|
||||
// installs, so it can't contain a space.
|
||||
repo_id: `${backend}-setup`,
|
||||
cmd: cmd,
|
||||
remote_host: _envState.remoteHost || undefined,
|
||||
ssh_port: _getPort(_envState.remoteHost) || undefined,
|
||||
@@ -1780,7 +1782,7 @@ async function _fetchDependencies() {
|
||||
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
|
||||
return;
|
||||
}
|
||||
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
const payload = { repo_id: reqBody.repo_id, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
_addTask(data.session_id, `${backend} setup`, 'download', payload);
|
||||
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1062,15 +1062,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch chat to agent mode if not already */
|
||||
function _ensureAgentMode() {
|
||||
const ab = document.getElementById('mode-agent-btn');
|
||||
const cb = document.getElementById('mode-chat-btn');
|
||||
if (ab && !ab.classList.contains('active')) {
|
||||
ab.click();
|
||||
}
|
||||
}
|
||||
|
||||
export function init(apiBase) {
|
||||
API_BASE = apiBase;
|
||||
initLibrary({
|
||||
@@ -7415,7 +7406,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// Doc was opened last → it goes in front of the email windows (clears the
|
||||
// email-front flag; the doc/email z-index alternation lives in CSS).
|
||||
document.body.classList.remove('email-front');
|
||||
_ensureAgentMode();
|
||||
_markDocVisibleState(_lastSessionId, 'open');
|
||||
|
||||
document.body.classList.add('doc-view');
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Own both completion paths of an animated close so neither can outlive it.
|
||||
const _pendingCloses = new WeakMap();
|
||||
|
||||
function cancelClose(modal) {
|
||||
const pending = _pendingCloses.get(modal);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pending.content.removeEventListener('animationend', pending.onEnd);
|
||||
pending.content.classList.remove('modal-closing');
|
||||
_pendingCloses.delete(modal);
|
||||
}
|
||||
|
||||
export function showModal(modal) {
|
||||
if (!modal) return;
|
||||
cancelClose(modal);
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function hideModal(modal) {
|
||||
if (!modal) return;
|
||||
cancelClose(modal);
|
||||
const content = modal.querySelector('.modal-content, .settings-modal-content');
|
||||
// A minimized window has no exit animation. Registering a listener here
|
||||
// would let its next entrance animation close the newly opened window.
|
||||
if (!content || modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const pending = { content, timer: null, onEnd: null };
|
||||
const finish = () => {
|
||||
if (_pendingCloses.get(modal) !== pending) return;
|
||||
cancelClose(modal);
|
||||
modal.classList.add('hidden');
|
||||
};
|
||||
pending.onEnd = event => {
|
||||
if (event.target === content) finish();
|
||||
};
|
||||
_pendingCloses.set(modal, pending);
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', pending.onEnd);
|
||||
pending.timer = setTimeout(finish, 250);
|
||||
}
|
||||
+11
-10
@@ -128,7 +128,7 @@ async function _ensureDefaultPendingChat() {
|
||||
if (!_deps || _defaultChatPickInFlight) return;
|
||||
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
|
||||
const pending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (pending && pending.modelId) return;
|
||||
if (pending && pending.modelId && pending.source !== 'fallback') return;
|
||||
_defaultChatPickInFlight = true;
|
||||
const seq = ++_defaultPendingSeq;
|
||||
try {
|
||||
@@ -954,15 +954,6 @@ export function updateModelPicker() {
|
||||
}
|
||||
}
|
||||
const latestPending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (
|
||||
!currentSessionId &&
|
||||
!_autoSelectingDefault &&
|
||||
window.modelsModule &&
|
||||
window.modelsModule.getCachedItems &&
|
||||
(!modelId || (latestPending && latestPending.source === 'fallback'))
|
||||
) {
|
||||
_ensureDefaultPendingChat();
|
||||
}
|
||||
|
||||
let displayName = modelId ? modelId.split('/').pop() : 'Select model';
|
||||
const routeItems = window.modelsModule?.getCachedItems?.() || [];
|
||||
@@ -994,6 +985,16 @@ export function updateModelPicker() {
|
||||
label.textContent = displayName;
|
||||
}
|
||||
_updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
|
||||
// A cached default can resolve synchronously and re-render the picker.
|
||||
if (
|
||||
!currentSessionId &&
|
||||
!_autoSelectingDefault &&
|
||||
window.modelsModule &&
|
||||
window.modelsModule.getCachedItems &&
|
||||
(!modelId || (latestPending && latestPending.source === 'fallback'))
|
||||
) {
|
||||
_ensureDefaultPendingChat();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reasoning effort control for models supporting reasoning levels ──
|
||||
|
||||
@@ -941,13 +941,32 @@ function _exitHistorySelectMode() {
|
||||
_renderJobs();
|
||||
}
|
||||
|
||||
async function _deleteResearchFromDisk(id) {
|
||||
const response = await fetch(`${_apiBase}/api/research/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error('Research deletion failed');
|
||||
}
|
||||
|
||||
function _showDeleteFailure(count) {
|
||||
if (typeof uiModule !== 'undefined' && uiModule?.showError) {
|
||||
uiModule.showError(`Could not delete ${count} research ${count === 1 ? 'report' : 'reports'}. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function _deleteHistoryJobs(ids) {
|
||||
const selected = new Set(ids);
|
||||
const selectedJobs = jobs.getJobs().filter(job => selected.has(job.id));
|
||||
await Promise.all(selectedJobs.filter(job => job.status === 'done').map(async job => {
|
||||
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
|
||||
let failed = 0;
|
||||
await Promise.all(selectedJobs.map(async job => {
|
||||
try {
|
||||
if (job.status === 'done') await _deleteResearchFromDisk(job.id);
|
||||
_historySelectedIds.delete(job.id);
|
||||
jobs.removeJob(job.id);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}));
|
||||
selectedJobs.forEach(job => jobs.removeJob(job.id));
|
||||
if (failed) _showDeleteFailure(failed);
|
||||
return failed === 0;
|
||||
}
|
||||
|
||||
async function _bulkDeleteHistory() {
|
||||
@@ -957,8 +976,8 @@ async function _bulkDeleteHistory() {
|
||||
const ok = await window.styledConfirm(`Delete ${ids.length} research ${ids.length === 1 ? 'report' : 'reports'}?`, { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
await _deleteHistoryJobs(ids);
|
||||
_exitHistorySelectMode();
|
||||
if (await _deleteHistoryJobs(ids)) _exitHistorySelectMode();
|
||||
else _updateHistoryBulkBar();
|
||||
}
|
||||
|
||||
async function _clearHistory() {
|
||||
@@ -1296,7 +1315,12 @@ function _buildJobCard(job) {
|
||||
const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
|
||||
try {
|
||||
await _deleteResearchFromDisk(job.id);
|
||||
} catch {
|
||||
_showDeleteFailure(1);
|
||||
return;
|
||||
}
|
||||
overflow.close();
|
||||
_animateOutThenRemove(card, () => jobs.removeJob(job.id));
|
||||
});
|
||||
|
||||
+44
-15
@@ -353,36 +353,38 @@ function _installHistoryPager(id, pageInfo, modelName) {
|
||||
handler: null,
|
||||
};
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (!_historyPager || _historyPager.loading || _historyPager.done) return;
|
||||
if (_historyPager.sessionId !== currentSessionId) return;
|
||||
if (box.scrollTop > 90) return;
|
||||
const loadOlder = async (force = false) => {
|
||||
const pager = _historyPager;
|
||||
if (!pager || pager.loading || pager.done) return;
|
||||
if (pager.sessionId !== currentSessionId) return;
|
||||
if (!force && box.scrollTop > 90) return;
|
||||
|
||||
const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit);
|
||||
const nextLimit = _historyPager.offset - nextOffset;
|
||||
const nextOffset = Math.max(0, pager.offset - pager.limit);
|
||||
const nextLimit = pager.offset - nextOffset;
|
||||
if (nextLimit <= 0) {
|
||||
_historyPager.done = true;
|
||||
pager.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_historyPager.loading = true;
|
||||
pager.loading = true;
|
||||
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
|
||||
const beforeHeight = box.scrollHeight;
|
||||
try {
|
||||
const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset }));
|
||||
const res = await fetch(_historyUrl(pager.sessionId, { limit: nextLimit, offset: nextOffset }));
|
||||
if (!res.ok) throw new Error(`Chat history request failed (HTTP ${res.status})`);
|
||||
const data = await res.json();
|
||||
if (!_historyPager || _historyPager.sessionId !== currentSessionId) return;
|
||||
if (_historyPager !== pager || pager.sessionId !== currentSessionId) return;
|
||||
const newEls = [];
|
||||
for (const msg of data.history || []) {
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||
const els = _renderHistoryMessage(msg, _historyPager.modelName);
|
||||
const els = _renderHistoryMessage(msg, pager.modelName);
|
||||
if (Array.isArray(els)) newEls.push(...els);
|
||||
}
|
||||
for (const el of newEls) {
|
||||
box.insertBefore(el, anchor || box.firstChild);
|
||||
}
|
||||
_historyPager.offset = Number(data.offset || nextOffset);
|
||||
_historyPager.done = !data.has_more_before;
|
||||
pager.offset = Number(data.offset || nextOffset);
|
||||
pager.done = !data.has_more_before;
|
||||
if (window.hljs) {
|
||||
newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block)));
|
||||
}
|
||||
@@ -391,16 +393,42 @@ function _installHistoryPager(id, pageInfo, modelName) {
|
||||
} catch (e) {
|
||||
console.warn('Failed to load older chat history:', e);
|
||||
} finally {
|
||||
if (_historyPager) _historyPager.loading = false;
|
||||
pager.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
_historyPager.loadOlder = loadOlder;
|
||||
_historyPager.handler = () => {
|
||||
if (box.scrollTop <= 90) loadOlder();
|
||||
if (box.scrollTop <= 90 && _historyPager && !_historyPager.loading) {
|
||||
_historyPager.pending = loadOlder();
|
||||
}
|
||||
};
|
||||
box.addEventListener('scroll', _historyPager.handler, { passive: true });
|
||||
}
|
||||
|
||||
export async function prepareHistoryForPrint() {
|
||||
const pager = _historyPager;
|
||||
if (!pager) return;
|
||||
const ensureCurrentChat = () => {
|
||||
if (_historyPager !== pager || currentSessionId !== pager.sessionId) {
|
||||
throw new Error('Chat changed while preparing the PDF. Export again from the intended chat.');
|
||||
}
|
||||
};
|
||||
while (!pager.done) {
|
||||
await pager.pending;
|
||||
ensureCurrentChat();
|
||||
if (pager.done) break;
|
||||
const previousOffset = pager.offset;
|
||||
pager.pending = pager.loadOlder(true);
|
||||
await pager.pending;
|
||||
ensureCurrentChat();
|
||||
if (!pager.done && pager.offset >= previousOffset) {
|
||||
throw new Error('Could not load the complete chat for PDF export. Please try again.');
|
||||
}
|
||||
}
|
||||
ensureCurrentChat();
|
||||
}
|
||||
|
||||
function _getIncognitoIds() {
|
||||
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
@@ -4061,6 +4089,7 @@ const sessionModule = {
|
||||
renderSessionList,
|
||||
loadSessions,
|
||||
selectSession,
|
||||
prepareHistoryForPrint,
|
||||
createDirectChat,
|
||||
materializePendingSession,
|
||||
preMaterializePendingSession,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import { makeWindowDraggable } from '../windowDrag.js';
|
||||
import { clearDockSide } from '../modalSnap.js';
|
||||
import { toggle as restoreMinimizedModal } from '../modalManager.js';
|
||||
import { showModal, hideModal } from '../modalVisibility.js';
|
||||
|
||||
const _dragBound = new WeakSet();
|
||||
const _closeBound = new WeakSet();
|
||||
@@ -147,30 +149,16 @@ export function bindSettingsClose(modalEl, options = {}) {
|
||||
|
||||
export function showSettingsModal(modalEl) {
|
||||
if (!modalEl) return;
|
||||
if (restoreMinimizedModal(modalEl.id)) {
|
||||
showModal(modalEl);
|
||||
return;
|
||||
}
|
||||
if (modalEl.classList.contains('hidden')) {
|
||||
resetSettingsWindowPlacement(modalEl);
|
||||
}
|
||||
modalEl.classList.remove('hidden');
|
||||
showModal(modalEl);
|
||||
}
|
||||
|
||||
export function hideSettingsModal(modalEl) {
|
||||
if (!modalEl) return;
|
||||
|
||||
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
|
||||
if (content && !content.classList.contains('modal-closing')) {
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', () => {
|
||||
modalEl.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}, { once: true });
|
||||
setTimeout(() => {
|
||||
if (!modalEl.classList.contains('hidden')) {
|
||||
modalEl.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
modalEl.classList.add('hidden');
|
||||
hideModal(modalEl);
|
||||
}
|
||||
|
||||
+34
-8
@@ -999,6 +999,7 @@ function renderSkillsList() {
|
||||
// the footer too so it's not buried under the "⋯" menu.
|
||||
const testBtn = document.createElement('button');
|
||||
testBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
|
||||
testBtn.dataset.act = 'test';
|
||||
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Test';
|
||||
testBtn.title = 'Test this skill — run it + AI judge';
|
||||
testBtn.addEventListener('click', (e) => {
|
||||
@@ -1013,14 +1014,10 @@ function renderSkillsList() {
|
||||
const _origHTML = testBtn.innerHTML;
|
||||
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Starting…';
|
||||
Promise.resolve(_testSkill(card, name)).finally(() => {
|
||||
// The preview gets overwritten by _testSkill, which removes the
|
||||
// testBtn from the DOM. The cleanup below only matters if the
|
||||
// button still exists (e.g. _testSkill bailed early).
|
||||
if (document.body.contains(testBtn)) {
|
||||
testBtn.disabled = false;
|
||||
testBtn.dataset.busy = '';
|
||||
testBtn.innerHTML = _origHTML;
|
||||
}
|
||||
// Source controls are detached during results and reused by Edit.
|
||||
testBtn.disabled = false;
|
||||
testBtn.dataset.busy = '';
|
||||
testBtn.innerHTML = _origHTML;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1285,6 +1282,10 @@ function _toggleSkillEdit(card, name) {
|
||||
if (!card._mdLoaded) return;
|
||||
const preview = card.querySelector('.skill-card-preview');
|
||||
if (!preview) return;
|
||||
if (card._skillSourceNodes) {
|
||||
preview.replaceChildren(...card._skillSourceNodes);
|
||||
card._skillSourceNodes = null;
|
||||
}
|
||||
const existing = preview.querySelector('.skill-md-editor');
|
||||
if (existing) {
|
||||
// Already editing — treat Edit as Save.
|
||||
@@ -1305,7 +1306,26 @@ function _toggleSkillEdit(card, name) {
|
||||
ta.focus();
|
||||
// Flip the Edit button label to "Save".
|
||||
const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent));
|
||||
const editLabel = editBtn?.innerHTML;
|
||||
if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><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';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'doclib-card-text-btn doclib-card-action-btn';
|
||||
cancel.textContent = 'Cancel';
|
||||
const testBtn = preview.querySelector('[data-act="test"]');
|
||||
const testDisplay = testBtn?.style.display;
|
||||
if (testBtn) testBtn.style.display = 'none';
|
||||
card._cancelSkillEdit = () => {
|
||||
ta.remove();
|
||||
if (pre) pre.style.removeProperty('display');
|
||||
if (editBtn) editBtn.innerHTML = editLabel;
|
||||
cancel.remove();
|
||||
if (testBtn) testBtn.style.display = testDisplay;
|
||||
card._cancelSkillEdit = null;
|
||||
card._fillH?.();
|
||||
};
|
||||
cancel.addEventListener('click', (e) => { e.stopPropagation(); card._cancelSkillEdit?.(); });
|
||||
if (editBtn) editBtn.after(cancel);
|
||||
}
|
||||
|
||||
async function _saveSkillEdit(card, name) {
|
||||
@@ -1454,6 +1474,12 @@ async function _testSkill(card, name, force = false) {
|
||||
if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name);
|
||||
const preview = card.querySelector('.skill-card-preview');
|
||||
if (!preview) return;
|
||||
if (!preview.querySelector('.skill-test')) {
|
||||
card._cancelSkillEdit?.();
|
||||
// Preserve the source nodes and handlers. Result actions are nested in
|
||||
// .skill-test, so they cannot be insertBefore references on the preview.
|
||||
card._skillSourceNodes = Array.from(preview.childNodes);
|
||||
}
|
||||
preview.innerHTML =
|
||||
'<div class="skill-test"><div class="skill-test-log"></div>' +
|
||||
'<div class="skill-test-verdict"></div></div>';
|
||||
|
||||
+16
-16
@@ -7,6 +7,8 @@ import { initColorPickers, attachColorPicker } from './colorPicker.js?v=20260910
|
||||
import { hexToRgb } from './color/hex.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js?v=20260910responsivebounds1';
|
||||
import { toggle as restoreMinimizedModal } from './modalManager.js';
|
||||
import { showModal, hideModal } from './modalVisibility.js';
|
||||
|
||||
export const THEMES = {
|
||||
dark: { bg:'#282c34', fg:'#9cdef2', panel:'#111111', border:'#355a66', red:'#e06c75' },
|
||||
@@ -1570,31 +1572,29 @@ export function makeDraggable(el, handle) {
|
||||
}
|
||||
|
||||
// Toggle the popup
|
||||
export function openPopup() {
|
||||
const modal = document.getElementById('theme-modal');
|
||||
if (!modal) return;
|
||||
restoreMinimizedModal(modal.id);
|
||||
showModal(modal);
|
||||
}
|
||||
|
||||
export function togglePopup() {
|
||||
const modal = document.getElementById('theme-modal');
|
||||
if (!modal) return;
|
||||
const visible = !modal.classList.contains('hidden');
|
||||
const visible = !modal.classList.contains('hidden')
|
||||
&& !modal.classList.contains('modal-minimized')
|
||||
&& !modal.querySelector('.modal-closing');
|
||||
if (visible) {
|
||||
modal.classList.add('hidden');
|
||||
closePopup();
|
||||
} else {
|
||||
modal.classList.remove('hidden');
|
||||
openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
export function closePopup() {
|
||||
const modal = document.getElementById('theme-modal');
|
||||
if (!modal) return;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (content && !content.classList.contains('modal-closing')) {
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', () => {
|
||||
modal.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}, { once: true });
|
||||
setTimeout(() => { if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
|
||||
} else {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
hideModal(modal);
|
||||
}
|
||||
|
||||
// Expose for app.js wiring + AI ui_control
|
||||
@@ -2278,7 +2278,7 @@ function _initEmbers() {
|
||||
draw();
|
||||
}
|
||||
|
||||
const themeModule = { initThemeUI, togglePopup, closePopup, makeDraggable,
|
||||
const themeModule = { initThemeUI, openPopup, togglePopup, closePopup, makeDraggable,
|
||||
THEMES, applyColors, applyFontDensity, applyBgPattern,
|
||||
applyBgEffectColor, applyBgEffectIntensity, applyBgEffectSize, applyBgEffectSpeed,
|
||||
applyFrostedGlass,
|
||||
|
||||
+4
-2
@@ -776,14 +776,16 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
|
||||
if (titleEl) titleEl.textContent = title || 'Confirm';
|
||||
msgEl.textContent = message;
|
||||
// Labels sit in spans so CSS can nudge the text without moving the button.
|
||||
const btnLabel = (text) => Object.assign(document.createElement('span'), { className: 'styled-confirm-btn-label', textContent: text });
|
||||
// Use the affirmative label consistently for generic confirmations.
|
||||
okBtn.textContent = confirmText === 'OK' ? 'Yes' : confirmText;
|
||||
okBtn.replaceChildren(btnLabel(confirmText === 'OK' ? 'Yes' : confirmText));
|
||||
cancelBtn.textContent = '';
|
||||
const cancelLabel = document.createElement('span');
|
||||
cancelLabel.className = 'styled-confirm-cancel-label';
|
||||
cancelLabel.textContent = cancelText;
|
||||
cancelBtn.appendChild(cancelLabel);
|
||||
altBtn.textContent = alternateText || '';
|
||||
altBtn.replaceChildren(btnLabel(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';
|
||||
|
||||
+24
-23
@@ -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-v1135-pr40-editor-precache';
|
||||
const CACHE_NAME = 'odysseus-v1137-pr40-editor-precache';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -38,30 +38,31 @@ const KATEX_FONTS = [
|
||||
// Both are fetched at install time, in the background. Entries must match the
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/static/js/modalVisibility.js',
|
||||
'/static/js/compare/sse.js',
|
||||
'/',
|
||||
'/static/css/00-tokens.css?v=20261007detailscroll1',
|
||||
'/static/css/01-agent-chat.css?v=20261007detailscroll1',
|
||||
'/static/css/02-compare.css?v=20261007detailscroll1',
|
||||
'/static/css/03-agent-chat.css?v=20261007detailscroll1',
|
||||
'/static/css/04-memory.css?v=20261007detailscroll1',
|
||||
'/static/css/05-documents.css?v=20261007detailscroll1',
|
||||
'/static/css/06-admin-settings.css?v=20261007detailscroll1',
|
||||
'/static/css/07-documents.css?v=20261007detailscroll1',
|
||||
'/static/css/08-skills.css?v=20261007detailscroll1',
|
||||
'/static/css/09-gallery.css?v=20261007detailscroll1',
|
||||
'/static/css/10-cookbook.css?v=20261007detailscroll1',
|
||||
'/static/css/11-tasks.css?v=20261007detailscroll1',
|
||||
'/static/css/12-gallery.css?v=20261007detailscroll1',
|
||||
'/static/css/13-image-editor.css?v=20261007detailscroll1',
|
||||
'/static/css/14-email.css?v=20261007detailscroll1',
|
||||
'/static/css/15-notes.css?v=20261007detailscroll1',
|
||||
'/static/css/16-calendar.css?v=20261007detailscroll1',
|
||||
'/static/css/17-research.css?v=20261007detailscroll1',
|
||||
'/static/css/documents-gallery-editor.css?v=20261007detailscroll1',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1',
|
||||
'/static/app.js?v=20261007detailscroll1',
|
||||
'/static/css/00-tokens.css?v=20261021confirmalign1',
|
||||
'/static/css/01-agent-chat.css?v=20261021confirmalign1',
|
||||
'/static/css/02-compare.css?v=20261021confirmalign1',
|
||||
'/static/css/03-agent-chat.css?v=20261021confirmalign1',
|
||||
'/static/css/04-memory.css?v=20261021confirmalign1',
|
||||
'/static/css/05-documents.css?v=20261021confirmalign1',
|
||||
'/static/css/06-admin-settings.css?v=20261021confirmalign1',
|
||||
'/static/css/07-documents.css?v=20261021confirmalign1',
|
||||
'/static/css/08-skills.css?v=20261021confirmalign1',
|
||||
'/static/css/09-gallery.css?v=20261021confirmalign1',
|
||||
'/static/css/10-cookbook.css?v=20261021confirmalign1',
|
||||
'/static/css/11-tasks.css?v=20261021confirmalign1',
|
||||
'/static/css/12-gallery.css?v=20261021confirmalign1',
|
||||
'/static/css/13-image-editor.css?v=20261021confirmalign1',
|
||||
'/static/css/14-email.css?v=20261021confirmalign1',
|
||||
'/static/css/15-notes.css?v=20261021confirmalign1',
|
||||
'/static/css/16-calendar.css?v=20261021confirmalign1',
|
||||
'/static/css/17-research.css?v=20261021confirmalign1',
|
||||
'/static/css/documents-gallery-editor.css?v=20261021confirmalign1',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1',
|
||||
'/static/app.js?v=20261021confirmalign1',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
'/static/js/ui.js?v=20260916largetoolscroll1',
|
||||
|
||||
@@ -3,7 +3,7 @@ from tests.helpers.db_stubs import make_core_db_stub
|
||||
|
||||
|
||||
def test_text_len_ignores_non_string_values(monkeypatch):
|
||||
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"])
|
||||
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion", "ChatMessage", "Session"])
|
||||
cli = load_script("odysseus-docs")
|
||||
|
||||
assert cli._text_len("hello") == 5
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Regression for #2123 (CLI part): `odysseus-docs search` must match
|
||||
multi-word queries and treat % / _ literally.
|
||||
|
||||
Old code built one ``%{query}%`` LIKE over title/current_content, so
|
||||
"machine learning" only matched the exact adjacent phrase, and a literal
|
||||
"%" matched every document.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import Document
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
|
||||
def _cli_with_documents(monkeypatch, tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'docs_cli_search.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
Document(
|
||||
id="doc-multiword",
|
||||
title="ML notes",
|
||||
current_content="machine notes on learning",
|
||||
),
|
||||
Document(
|
||||
id="doc-percent",
|
||||
title="Fabric",
|
||||
current_content="100% cotton",
|
||||
),
|
||||
Document(
|
||||
id="doc-underscore",
|
||||
title="sale_2024",
|
||||
current_content="spring campaign",
|
||||
),
|
||||
Document(
|
||||
id="doc-backslash",
|
||||
title="Path",
|
||||
current_content=r"C:\photos\sunset",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
cli = load_script("odysseus-docs")
|
||||
monkeypatch.setattr(cli, "SessionLocal", session_factory)
|
||||
return cli
|
||||
|
||||
|
||||
def _search_ids(cli, capsys, query):
|
||||
cli.cmd_search(SimpleNamespace(query=query, limit=50, pretty=False))
|
||||
return {doc["id"] for doc in json.loads(capsys.readouterr().out)}
|
||||
|
||||
|
||||
def test_search_matches_words_in_any_order(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, "machine learning") == {"doc-multiword"}
|
||||
|
||||
|
||||
def test_search_escapes_percent_wildcard(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, "%") == {"doc-percent"}
|
||||
|
||||
|
||||
def test_search_escapes_underscore_wildcard(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
# Unescaped, "_" matches any single char so every document matches.
|
||||
assert _search_ids(cli, capsys, "_") == {"doc-underscore"}
|
||||
|
||||
|
||||
def test_search_escapes_backslash(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, "\\") == {"doc-backslash"}
|
||||
|
||||
|
||||
def test_search_whitespace_query_returns_all(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, " ") == {
|
||||
"doc-multiword",
|
||||
"doc-percent",
|
||||
"doc-underscore",
|
||||
"doc-backslash",
|
||||
}
|
||||
@@ -348,6 +348,8 @@ function moduleSource(relativePath) {
|
||||
},
|
||||
makeWindowDraggable: (...args) => dragCalls.push(args),
|
||||
clearDockSide: (...args) => dockCalls.push(args),
|
||||
restoreMinimizedModal: () => false,
|
||||
clearTimeout() {},
|
||||
setTimeout: callback => { callback(); return 1; },
|
||||
WeakSet,
|
||||
};
|
||||
@@ -356,6 +358,7 @@ function moduleSource(relativePath) {
|
||||
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
|
||||
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
|
||||
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
|
||||
vm.runInContext(moduleSource('../modalVisibility.js'), context, { filename: 'modalVisibility.js' });
|
||||
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
|
||||
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const REAL_MODULES = new Set([
|
||||
path.join(JS, 'settings/sidebar.js'),
|
||||
path.join(JS, 'settings/navigation.js'),
|
||||
path.join(JS, 'settings/lifecycle.js'),
|
||||
path.join(JS, 'modalVisibility.js'),
|
||||
path.join(JS, 'settings/api.js'),
|
||||
path.join(JS, 'settings/speech.js'),
|
||||
path.join(JS, 'settings/writingStyle.js'),
|
||||
@@ -869,6 +870,7 @@ const STUBS = new Map([
|
||||
clearDockSide() {},
|
||||
},
|
||||
],
|
||||
[path.join(JS, 'modalManager.js'), { toggle() { return false; } }],
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""New usernames must be usable as a URL path segment and not carry control characters."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tests.test_auth_policy import _auth_module, _make_manager, _setup_endpoint, _signup_endpoint
|
||||
|
||||
BAD = ["a/b", "../x", "a\\b", "tab\tname", "new\nline", "..", ".", "u" * 65]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", BAD)
|
||||
def test_username_error_rejects(name):
|
||||
assert _auth_module().username_error(name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["alice", "Bob Smith", "j.doe", "user@example.com", "日本語", "u" * 64])
|
||||
def test_username_error_accepts(name):
|
||||
assert _auth_module().username_error(name) is None
|
||||
|
||||
|
||||
def test_username_error_requires_a_name():
|
||||
assert _auth_module().username_error(" ") == "Username is required"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["a/b", "u" * 65])
|
||||
def test_setup_rejects_bad_username(tmp_path, name):
|
||||
mgr = _make_manager(tmp_path)
|
||||
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(body=SetupRequest(username=name, password="long-enough-pw"), request=request))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert not mgr.is_configured
|
||||
|
||||
|
||||
def test_signup_rejects_bad_username(tmp_path):
|
||||
mgr = _make_manager(tmp_path)
|
||||
mgr.create_user("admin", "admin-password", is_admin=True)
|
||||
mgr.signup_enabled = True
|
||||
endpoint, SignupRequest = _signup_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(body=SignupRequest(username="a/b", password="long-enough-pw"), request=request))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "a/b" not in mgr.users
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The shipped CSS must print every message beyond the screen viewport."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfReader
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node is required for Chromium printing")
|
||||
def test_chat_print_preserves_the_complete_transcript(tmp_path):
|
||||
script = r'''
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const source = fs.readFileSync('static/index.html', 'utf8');
|
||||
const links = [...source.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*>/g)]
|
||||
.map(match => match[0]).join('\n');
|
||||
const messages = Array.from({length: 24}, (_, index) => {
|
||||
const marker = 'EXPORTSENTINEL' + String(index + 1).padStart(2, '0');
|
||||
const paragraphs = Array.from({length:4}, () =>
|
||||
'<p>This synthetic message must survive printing beyond the screen viewport. '
|
||||
+ 'Every paragraph belongs to the saved conversation.</p>').join('');
|
||||
return `<div class="msg ${index % 2 ? 'msg-ai' : 'msg-user'}"><div class="body"><p>${marker}</p>${paragraphs}</div></div>`;
|
||||
}).join('');
|
||||
const browser = await chromium.launch({headless:true});
|
||||
try {
|
||||
const results=[];
|
||||
for (const [layout,viewport] of [['desktop',{width:1440,height:1000}],['mobile',{width:390,height:844}]]) {
|
||||
const page=await browser.newPage({viewport});
|
||||
await page.goto(origin+'/static/css/00-tokens.css');
|
||||
await page.setContent(`<html><head>${links}</head><body><main class="chat-container"><div id="chat-history">${messages}</div></main></body></html>`,{waitUntil:'load'});
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
const file=path.join(process.env.ODYSSEUS_PDF_OUTPUT_DIR,layout+'.pdf');
|
||||
await page.pdf({path:file,format:'A4',printBackground:true});
|
||||
results.push({layout,file});
|
||||
await page.close();
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
} finally {await browser.close();}
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module", "-e", script], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=90,
|
||||
env={**os.environ, "ODYSSEUS_PDF_OUTPUT_DIR": str(tmp_path)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
results = json.loads(result.stdout)
|
||||
assert {row["layout"] for row in results} == {"desktop", "mobile"}
|
||||
for row in results:
|
||||
pdf = PdfReader(row["file"])
|
||||
text = "".join("".join(page.extract_text().split()) for page in pdf.pages)
|
||||
assert len(pdf.pages) > 1, row["layout"]
|
||||
for index in range(1, 25):
|
||||
assert f"EXPORTSENTINEL{index:02d}" in text, (row["layout"], index)
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -1026,3 +1027,36 @@ def test_validate_serve_cmd_rejects_unrelated_subshell_pipelines():
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_serve_cmd(cmd)
|
||||
|
||||
|
||||
def _dependency_recipe_pip_commands():
|
||||
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook-deps-recipes.js").read_text(encoding="utf-8")
|
||||
return re.findall(r"pip:\s*\{\s*commands:\s*\['((?:[^'\\]|\\.)*)'\]\s*\}", src)
|
||||
|
||||
|
||||
def test_validate_serve_cmd_accepts_single_line_dependency_recipes():
|
||||
# The Dependencies recipe "Run" button posts these to /api/model/serve.
|
||||
cmds = _dependency_recipe_pip_commands()
|
||||
assert 'uv pip install -U vllm --torch-backend auto' in cmds
|
||||
assert 'uv pip install -U "sglang[all]" --torch-backend auto' in cmds
|
||||
for cmd in cmds:
|
||||
assert _validate_serve_cmd(cmd) == cmd
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"uv run python -c 'print(1)'",
|
||||
"uv tool install ruff",
|
||||
"uv pip uninstall vllm",
|
||||
"uv",
|
||||
])
|
||||
def test_validate_serve_cmd_allows_uv_only_for_pip_install(cmd):
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_serve_cmd(cmd)
|
||||
|
||||
|
||||
def test_dependency_recipe_run_sends_a_valid_pip_repo_id():
|
||||
# A pip-install repo_id must match the server's package-spec regex; the old
|
||||
# "<backend> setup" value had a space and every Run got "Invalid pip package name".
|
||||
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook.js").read_text(encoding="utf-8")
|
||||
assert "repo_id: `${backend}-setup`," in src
|
||||
assert "repo_id: `${backend} setup`" not in src
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Demoting an admin purges their API tokens (issue #6831).
|
||||
|
||||
Token management is admin-only and scoped to the token's owner, so once the
|
||||
owner is demoted nobody can list or revoke their tokens any more.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_delete_user_revokes_api_tokens import (
|
||||
_auth_module,
|
||||
_FakeApiToken,
|
||||
_FakeSession,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path, monkeypatch):
|
||||
auth_mod = _auth_module()
|
||||
monkeypatch.setattr(auth_mod, "_hash_password", lambda password: f"hash:{password}")
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_verify_password", lambda password, hashed: hashed == f"hash:{password}"
|
||||
)
|
||||
mgr = auth_mod.AuthManager(str(tmp_path / "auth.json"))
|
||||
assert mgr.create_user("admin", "secret-admin-pw", is_admin=True)
|
||||
assert mgr.create_user("adm2", "secret-adm2-pw", is_admin=True)
|
||||
assert mgr.create_user("bob", "secret-bob-pw", is_admin=False)
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_calls(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _fake_db_session():
|
||||
yield _FakeSession(calls)
|
||||
|
||||
db_stub = types.ModuleType("core.database")
|
||||
db_stub.get_db_session = _fake_db_session
|
||||
db_stub.ApiToken = _FakeApiToken
|
||||
monkeypatch.setitem(sys.modules, "core.database", db_stub)
|
||||
return calls
|
||||
|
||||
|
||||
def test_demote_revokes_api_tokens(manager, db_calls):
|
||||
result = manager.set_admin("adm2", False, "admin")
|
||||
|
||||
assert result.value == "ok"
|
||||
assert manager.is_admin("adm2") is False
|
||||
assert [("owner ==", "adm2")] in db_calls
|
||||
|
||||
|
||||
def test_promote_leaves_api_tokens_alone(manager, db_calls):
|
||||
manager.set_admin("bob", True, "admin")
|
||||
|
||||
assert db_calls == []
|
||||
|
||||
|
||||
def test_refused_demotion_leaves_api_tokens_alone(manager, db_calls):
|
||||
assert manager.set_admin("adm2", False, "bob").value == "not_authorized"
|
||||
assert db_calls == []
|
||||
|
||||
|
||||
def test_demote_fails_closed_when_token_purge_fails(manager, monkeypatch):
|
||||
@contextlib.contextmanager
|
||||
def _failing_db_session():
|
||||
raise RuntimeError("database unavailable")
|
||||
yield
|
||||
|
||||
db_stub = types.ModuleType("core.database")
|
||||
db_stub.get_db_session = _failing_db_session
|
||||
db_stub.ApiToken = _FakeApiToken
|
||||
monkeypatch.setitem(sys.modules, "core.database", db_stub)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
manager.set_admin("adm2", False, "admin")
|
||||
|
||||
assert manager.is_admin("adm2") is True
|
||||
@@ -58,6 +58,38 @@ def test_styled_confirm_cancel_or_close_label_is_shifted_without_moving_button()
|
||||
assert "top:2px;" in rule
|
||||
|
||||
|
||||
def test_styled_confirm_ok_and_alternate_labels_shift_without_moving_buttons():
|
||||
# Moving #styled-confirm-ok itself (position:relative; top:2px) left it 2px
|
||||
# below Cancel and the alternate button. The OK and alternate labels get the
|
||||
# same label nudge as Cancel instead, and no rule moves a footer button.
|
||||
css = app_css()
|
||||
|
||||
assert "okBtn.replaceChildren(btnLabel(" in _UI
|
||||
assert "altBtn.replaceChildren(btnLabel(" in _UI
|
||||
rule_start = css.index("#styled-confirm-ok .styled-confirm-btn-label")
|
||||
rule = css[rule_start:css.index("}", rule_start)]
|
||||
assert "#styled-confirm-alt .styled-confirm-btn-label" in rule
|
||||
assert "position:relative;" in rule and "top:2px;" in rule
|
||||
for selector in ("#styled-confirm-ok {", "#styled-confirm-alt {", "#styled-confirm-cancel {"):
|
||||
if selector in css:
|
||||
block = css[css.index(selector):].split("}", 1)[0]
|
||||
assert "top:" not in block.replace(" ", ""), selector
|
||||
|
||||
|
||||
def test_styled_confirm_buttons_grow_to_fit_wrapped_labels():
|
||||
# The global `button { height:32px }` held a two-line label (e.g.
|
||||
# "Delete chat and images") to one line's height, so its second line
|
||||
# rendered below the button. Confirm buttons must be allowed to grow,
|
||||
# with 32px kept as the minimum.
|
||||
css = app_css()
|
||||
|
||||
assert re.search(r"(^|\n)\s*button\s*\{\s*height:32px;", css)
|
||||
rule_start = css.index(".styled-confirm-box .confirm-btn {")
|
||||
rule = css[rule_start:rule_start + 120]
|
||||
assert "height:auto;" in rule
|
||||
assert "min-height:32px;" in rule
|
||||
|
||||
|
||||
def test_styled_dialogs_manage_focus():
|
||||
# A dialog is only really accessible if it restores focus to the trigger on
|
||||
# close and traps Tab while open. Both styledConfirm and styledPrompt should
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Large document pages survive the model-facing tool-output boundary."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.constants import MAX_OUTPUT_CHARS
|
||||
from src.tool_execution import format_tool_result
|
||||
from core import database
|
||||
from src.agent_tools import TOOL_HANDLERS
|
||||
from src.agent_tools.document_tools import get_active_document, set_active_document
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def documents(tmp_path, monkeypatch):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}")
|
||||
database.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(database, "SessionLocal", factory)
|
||||
saved_active = get_active_document()
|
||||
with factory() as db:
|
||||
db.add(database.Document(id="owned-document", owner="fixture-owner", title="Owned",
|
||||
language="text", current_content=""))
|
||||
db.commit()
|
||||
yield
|
||||
set_active_document(saved_active)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [None, 37, 20_000])
|
||||
def test_formatted_document_pages_reconstruct_the_entire_body(documents, limit):
|
||||
body = "".join(f"Row {i}: α, quote=\" and slash=\\\n" for i in range(1200))
|
||||
with database.SessionLocal() as db:
|
||||
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
|
||||
db.commit()
|
||||
|
||||
offset = 0
|
||||
chunks = []
|
||||
while True:
|
||||
args = {"action": "read", "document_id": "owned-document", "offset": offset}
|
||||
if limit is not None:
|
||||
args["limit"] = limit
|
||||
content = json.dumps(args)
|
||||
result = asyncio.run(TOOL_HANDLERS["manage_documents"](content, {"owner": "fixture-owner"}))
|
||||
page = result["document"]
|
||||
rendered = format_tool_result(f"manage_documents: {content[:80]}", result)
|
||||
end = page["next_offset"] if page["truncated"] else len(body)
|
||||
chunk = body[offset:end]
|
||||
assert chunk in rendered
|
||||
assert len(rendered) <= MAX_OUTPUT_CHARS
|
||||
assert rendered.endswith("\n```")
|
||||
if page["truncated"]:
|
||||
assert end > offset
|
||||
assert f"next_offset={end}" in rendered
|
||||
chunks.append(chunk)
|
||||
if not page["truncated"]:
|
||||
break
|
||||
offset = end
|
||||
assert "".join(chunks) == body
|
||||
|
||||
|
||||
def test_document_result_cannot_bypass_the_output_cap():
|
||||
rendered = format_tool_result("manage_documents: read", {
|
||||
"response": "x" * (MAX_OUTPUT_CHARS * 2),
|
||||
"document": {"offset": 0, "next_offset": None},
|
||||
"exit_code": 0,
|
||||
})
|
||||
assert len(rendered) < MAX_OUTPUT_CHARS + 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["", "short document"])
|
||||
@pytest.mark.parametrize("offset", [0, 100])
|
||||
def test_short_or_exhausted_read_has_no_continuation(documents, body, offset):
|
||||
with database.SessionLocal() as db:
|
||||
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
|
||||
db.commit()
|
||||
result = asyncio.run(TOOL_HANDLERS["manage_documents"](json.dumps({
|
||||
"action": "read", "document_id": "owned-document", "offset": offset,
|
||||
}), {"owner": "fixture-owner"}))
|
||||
assert result["document"]["content"] == body[offset:]
|
||||
assert result["document"]["next_offset"] is None
|
||||
assert not result["document"]["truncated"]
|
||||
assert format_tool_result("manage_documents: read", result) == result["response"]
|
||||
|
||||
|
||||
def test_oversized_header_fails_without_a_nonadvancing_page(documents):
|
||||
with database.SessionLocal() as db:
|
||||
db.query(database.Document).filter_by(id="owned-document").first().title = "x" * MAX_OUTPUT_CHARS
|
||||
db.commit()
|
||||
result = asyncio.run(TOOL_HANDLERS["manage_documents"](
|
||||
'{"action":"read","document_id":"owned-document"}', {"owner": "fixture-owner"}))
|
||||
assert result["exit_code"] == 1
|
||||
assert "too long" in result["error"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""MIME alternatives are selected independently of part order and codec labels."""
|
||||
from email import message_from_bytes
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_servers.email_server import _extract_text as mcp_text
|
||||
from routes.email_helpers import _extract_html, _extract_text as route_text
|
||||
|
||||
|
||||
def alternative(order, plain='Body text', html='<p>Body text</p>'):
|
||||
msg = MIMEMultipart('alternative')
|
||||
parts = {'plain': MIMEText(plain, 'plain', 'utf-8'), 'html': MIMEText(html, 'html', 'utf-8')}
|
||||
for kind in order:
|
||||
msg.attach(parts[kind])
|
||||
return message_from_bytes(msg.as_bytes())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
@pytest.mark.parametrize('order', [('html', 'plain'), ('plain', 'html')])
|
||||
def test_plain_alternative_wins_in_either_order(extract, order):
|
||||
assert extract(alternative(order)) == 'Body text'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_empty_plain_allows_html_fallback(extract):
|
||||
assert extract(alternative(('plain', 'html'), plain='', html='Hello<br>world & all')) == 'Hello\nworld & all'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_text_attachments_are_not_body_alternatives(extract):
|
||||
msg = MIMEMultipart('mixed')
|
||||
attachment = MIMEText('Attachment text', 'plain')
|
||||
attachment.add_header('Content-Disposition', 'attachment', filename='note.txt')
|
||||
msg.attach(attachment)
|
||||
msg.attach(MIMEText('<p>Body text</p>', 'html'))
|
||||
assert extract(msg) == 'Body text'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_multiple_plain_body_parts_remain_joined(extract):
|
||||
msg = MIMEMultipart('mixed')
|
||||
msg.attach(MIMEText('Part one', 'plain'))
|
||||
msg.attach(MIMEText('Part two', 'plain'))
|
||||
assert extract(msg) == 'Part one\nPart two'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
@pytest.mark.parametrize('kind', ['plain', 'html'])
|
||||
@pytest.mark.parametrize('multipart', [False, True])
|
||||
def test_unknown_charset_falls_back_to_utf8(extract, kind, multipart):
|
||||
body = 'Hello café' if kind == 'plain' else '<p>Hello café</p>'
|
||||
raw = f'Content-Type: text/{kind}; charset=x-unknown\r\nContent-Transfer-Encoding: 8bit\r\n\r\n'.encode()+body.encode()
|
||||
part = message_from_bytes(raw)
|
||||
if multipart:
|
||||
msg = MIMEMultipart('mixed');msg.attach(part)
|
||||
else:
|
||||
msg = part
|
||||
assert extract(msg) == 'Hello café'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_declared_latin1_is_preserved(extract):
|
||||
assert extract(MIMEText('café', 'plain', 'iso-8859-1')) == 'café'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('multipart', [False, True])
|
||||
def test_raw_html_unknown_charset_is_readable(multipart):
|
||||
part = message_from_bytes(b'Content-Type: text/html; charset=x-unknown\r\n\r\n<p>Hello caf\xc3\xa9</p>')
|
||||
if multipart:
|
||||
msg=MIMEMultipart('mixed');msg.attach(part)
|
||||
else:msg=part
|
||||
assert _extract_html(msg) == '<p>Hello café</p>'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_invalid_bytes_are_replaced_in_unknown_charset(extract):
|
||||
msg=message_from_bytes(b'Content-Type: text/plain; charset=x-unknown\r\n\r\nHello\xff')
|
||||
assert extract(msg) == 'Hello\ufffd'
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tests.helpers.database import isolated_session_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager_database(tmp_path):
|
||||
with isolated_session_database(tmp_path) as resources:
|
||||
yield resources
|
||||
|
||||
|
||||
def add_message(sm, monkeypatch, body):
|
||||
import routes.history.history_routes as history
|
||||
|
||||
monkeypatch.setattr(history, '_verify_session_owner', lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(history, 'effective_user', lambda request: 'u')
|
||||
monkeypatch.setattr(history, 'reserve_message_upload_references', lambda *args, **kwargs: None)
|
||||
route = next(r for r in history.setup_history_routes(sm).routes
|
||||
if r.path == '/api/session/{session_id}/message')
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return body
|
||||
|
||||
return asyncio.run(route.endpoint(Request(), 'sid'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
{'role': 'bogus', 'content': 'x'},
|
||||
{'role': 'system', 'content': 'x'},
|
||||
{'role': 'user', 'content': 123},
|
||||
{'content': ['a']},
|
||||
{'role': ['user'], 'content': 'x'},
|
||||
[1],
|
||||
])
|
||||
def test_bad_role_or_content_is_a_client_error(manager_database, monkeypatch, body):
|
||||
sm, _ = manager_database
|
||||
sm.create_session(session_id='sid', name='t', endpoint_url='x', model='m', rag=False, owner='u')
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
add_message(sm, monkeypatch, body)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert sm.get_session('sid').history == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body, role', [
|
||||
({'role': 'user', 'content': 'hi'}, 'user'),
|
||||
({'role': 'assistant', 'content': 'hello'}, 'assistant'),
|
||||
({'content': 'default role'}, 'assistant'),
|
||||
])
|
||||
def test_user_and_assistant_text_are_stored(manager_database, monkeypatch, body, role):
|
||||
sm, _ = manager_database
|
||||
sm.create_session(session_id='sid', name='t', endpoint_url='x', model='m', rag=False, owner='u')
|
||||
|
||||
assert add_message(sm, monkeypatch, body) == {'status': 'ok'}
|
||||
|
||||
stored = sm.get_session('sid').history
|
||||
assert [(m.role, m.content) for m in stored] == [(role, body['content'])]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""CPU-only hardware results and SSH probe context agree with GPU paths."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services.hwfit import hardware
|
||||
|
||||
WINDOWS_PROBE = hardware._detect_windows
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def probe(monkeypatch):
|
||||
monkeypatch.setattr(hardware, "_cache_by_host", {})
|
||||
for name in ("_remote_host", "_remote_port", "_remote_platform", "_last_gpu_error"):
|
||||
monkeypatch.setattr(hardware, name, None)
|
||||
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 64.0)
|
||||
monkeypatch.setattr(hardware, "_get_available_ram_gb", lambda: 48.0)
|
||||
monkeypatch.setattr(hardware, "_get_cpu_count", lambda: 16)
|
||||
monkeypatch.setattr(hardware, "_get_cpu_name", lambda: "Test CPU")
|
||||
monkeypatch.setattr(hardware, "_get_cpu_arch", lambda: "x86_64")
|
||||
monkeypatch.setattr(hardware, "_is_containerized", lambda: False)
|
||||
for name in ("_detect_windows", "_detect_apple_silicon", "_detect_nvidia", "_detect_amd"):
|
||||
monkeypatch.setattr(hardware, name, lambda: None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arch,backend", [("x86_64", "cpu_x86"), ("arm64", "cpu_arm")])
|
||||
@pytest.mark.parametrize("remote", [False, True])
|
||||
def test_cpu_only_result_has_empty_gpu_inventory(monkeypatch, arch, backend, remote):
|
||||
monkeypatch.setattr(hardware, "_get_cpu_arch", lambda: arch)
|
||||
options = {"host": "user@cpu-fixture", "ssh_port": "2222", "platform": "linux"} if remote else {}
|
||||
result = hardware.detect_system(**options)
|
||||
|
||||
assert result["has_gpu"] is False
|
||||
assert result["gpu_count"] == 0
|
||||
assert result["gpus"] == []
|
||||
assert result["gpu_groups"] == []
|
||||
assert result["homogeneous"] is True
|
||||
assert result["backend"] == backend
|
||||
assert result["cpu_arch"] == arch
|
||||
assert hardware.detect_system(**options) == result
|
||||
|
||||
|
||||
def test_windows_cpu_only_result_has_empty_gpu_inventory(monkeypatch):
|
||||
monkeypatch.setattr(hardware, "_detect_windows", WINDOWS_PROBE)
|
||||
monkeypatch.setattr(hardware, "_run", lambda _command: json.dumps({
|
||||
"ram_gb": 64.0, "avail_gb": 48.0, "cpu_cores": 16,
|
||||
"cpu_name": "Test CPU", "cpu_arch": "AMD64", "gpu_count": 0,
|
||||
}))
|
||||
|
||||
result = hardware.detect_system(host="user@windows-fixture", ssh_port="2222", platform="windows")
|
||||
|
||||
assert result["has_gpu"] is False
|
||||
assert result["gpus"] == []
|
||||
assert result["gpu_groups"] == []
|
||||
assert result["homogeneous"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["cpu", "gpu", "windows", "windows_failure", "connection_failure"])
|
||||
def test_remote_probe_clears_all_connection_context(monkeypatch, path):
|
||||
gpu = {
|
||||
"gpu_name": "Test GPU", "gpu_vram_gb": 16.0, "gpu_count": 1,
|
||||
"gpus": [{"index": 0, "name": "Test GPU", "vram_gb": 16.0}],
|
||||
"gpu_groups": [{"name": "Test GPU", "count": 1}],
|
||||
"homogeneous": True, "backend": "cuda",
|
||||
}
|
||||
if path == "gpu":
|
||||
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: gpu)
|
||||
elif path == "windows":
|
||||
monkeypatch.setattr(hardware, "_detect_windows", lambda: {"has_gpu": False, "backend": "cpu_x86"})
|
||||
elif path == "connection_failure":
|
||||
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 0.0)
|
||||
|
||||
result = hardware.detect_system(
|
||||
host="user@hardware-fixture", ssh_port="2222",
|
||||
platform="windows" if path.startswith("windows") else "linux", fresh=True,
|
||||
)
|
||||
|
||||
if path in {"windows_failure", "connection_failure"}:
|
||||
assert result["error"]
|
||||
elif path == "gpu":
|
||||
assert result["gpus"] == gpu["gpus"]
|
||||
assert result["gpu_groups"] == gpu["gpu_groups"]
|
||||
assert result["backend"] == "cuda"
|
||||
else:
|
||||
assert result["has_gpu"] is False
|
||||
assert (hardware._remote_host, hardware._remote_port, hardware._remote_platform) == (None, None, None)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Native memory ranking must not promote names on substring/first-person guesses."""
|
||||
import pytest
|
||||
|
||||
from src.memory import MemoryManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path):
|
||||
return MemoryManager(str(tmp_path))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('query', ['bitcoin price', 'list tasks', 'time forecast', 'summary'])
|
||||
def test_unrelated_query_does_not_force_name_into_results(manager, query):
|
||||
name = {'id':'name', 'text':'My name is Rowan Example'}
|
||||
match = {'id':'match', 'text':query}
|
||||
assert manager.get_relevant_memories(query, [name, match]) == [match]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('query, text', [
|
||||
('what is my email', 'email is contact@example.test'),
|
||||
('I like jazz', 'I like jazz music'),
|
||||
('remind me about dentist', 'dentist appointment reminder'),
|
||||
])
|
||||
def test_first_person_words_do_not_override_the_actual_topic(manager, query, text):
|
||||
name = {'id':'name', 'text':'My name is Rowan Example'}
|
||||
topic = {'id':'topic', 'text':text}
|
||||
assert manager.get_relevant_memories(query, [name, topic], max_items=1) == [topic]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('query', ['Who am I?', 'what am I called', 'my name', 'NAME:'])
|
||||
def test_explicit_identity_query_still_finds_name(manager, query):
|
||||
name = {'id':'name', 'text':'My name is Rowan Example'}
|
||||
assert manager.get_relevant_memories(query, [name]) == [name]
|
||||
|
||||
|
||||
def test_closer_match_outranks_identity_signal_and_threshold_still_applies(manager):
|
||||
name = {'id':'name', 'text':'My name is Rowan Example'}
|
||||
project = {'id':'project', 'text':'the project name is Polaris'}
|
||||
assert manager.get_relevant_memories('what is the project name', [name, project], max_items=1) == [project]
|
||||
assert manager.get_relevant_memories('who am I', [name], threshold=0.7) == []
|
||||
|
||||
|
||||
def test_name_is_still_searchable_by_its_actual_text_without_a_type_keyword(manager):
|
||||
name = {'id':'name', 'text':'My name is Rowan Example'}
|
||||
assert manager.get_relevant_memories('Rowan', [name]) == [name]
|
||||
|
||||
|
||||
def test_empty_queries_and_limits_keep_their_contract(manager):
|
||||
memories = [{'text':'tea preference'}, {'text':'tea recipe'}]
|
||||
assert manager.get_relevant_memories(' ', memories) == []
|
||||
assert manager.get_relevant_memories('tea', []) == []
|
||||
assert len(manager.get_relevant_memories('tea', memories, max_items=1)) == 1
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Real ESM/DOM regressions for Settings and Theme close/restore races."""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.stylesheets import stylesheet_link_tags
|
||||
|
||||
|
||||
@pytest.mark.parametrize('modal_id', ['settings-modal', 'theme-modal'])
|
||||
@pytest.mark.parametrize('width', [1440, 390])
|
||||
def test_modal_reopen_after_dock_close_and_pending_animation(modal_id, width):
|
||||
script = r"""
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({headless:true});
|
||||
try {
|
||||
const page = await browser.newPage({viewport:{width:WIDTH,height:1000}});
|
||||
await page.route('**/api/**', route => route.fulfill({json:{value:null}}));
|
||||
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js');
|
||||
await page.setContent('__STYLES__<div id="sidebar"></div>');
|
||||
const result = await page.evaluate(async id => {
|
||||
const html = await (await fetch('/static/index.html')).text();
|
||||
const source = new DOMParser().parseFromString(html,'text/html');
|
||||
document.body.append(source.getElementById(id));
|
||||
const modal = document.getElementById(id);
|
||||
const content = modal.querySelector('.modal-content');
|
||||
const manager = await import('/static/js/modalManager.js');
|
||||
let open, close;
|
||||
if(id === 'settings-modal') {
|
||||
const lifecycle = await import('/static/js/settings/lifecycle.js');
|
||||
open = () => lifecycle.showSettingsModal(modal);
|
||||
close = () => lifecycle.hideSettingsModal(modal);
|
||||
} else {
|
||||
const theme = await import('/static/js/theme.js');
|
||||
// The old sidebar simply removed hidden; the public opener now
|
||||
// also cancels a pending close and restores manager state.
|
||||
open = theme.openPopup || (() => modal.classList.remove('hidden'));
|
||||
close = () => theme.closePopup();
|
||||
}
|
||||
const visible = () => getComputedStyle(modal).display !== 'none';
|
||||
const wait = () => new Promise(resolve=>setTimeout(resolve,350));
|
||||
const states = {};
|
||||
if (id === 'theme-modal') {
|
||||
const theme = await import('/static/js/theme.js');
|
||||
theme.togglePopup();
|
||||
theme.togglePopup();
|
||||
theme.togglePopup();
|
||||
await wait();
|
||||
states.fastToggleReopens = visible();
|
||||
close();
|
||||
await wait();
|
||||
}
|
||||
open();
|
||||
manager.register(id,{closeFn:close,restoreFn:()=>{}});
|
||||
manager.minimize(id);
|
||||
manager.close(id);
|
||||
await wait();
|
||||
open();
|
||||
await wait();
|
||||
states.afterDockClose = visible();
|
||||
// An opening/descendant animation must not reach an orphan closer.
|
||||
content.dispatchEvent(new AnimationEvent('animationend',{bubbles:true,animationName:'modal-enter'}));
|
||||
states.afterOpeningAnimation = visible();
|
||||
// Begin a visible close, then reopen before its fallback expires.
|
||||
close();
|
||||
open();
|
||||
await wait();
|
||||
states.afterFastReopen = visible();
|
||||
manager.register(id,{closeFn:close,restoreFn:()=>{}});
|
||||
manager.minimize(id);
|
||||
open();
|
||||
states.directOpenRestoresMinimized = visible() && !manager.isMinimized(id);
|
||||
// A child animation must not terminate a normal parent close.
|
||||
close();
|
||||
content.querySelector('button').dispatchEvent(new AnimationEvent('animationend',{bubbles:true}));
|
||||
states.childAnimationDoesNotClose = !modal.classList.contains('hidden');
|
||||
content.dispatchEvent(new AnimationEvent('animationend',{bubbles:true,animationName:'modal-exit'}));
|
||||
states.normalCloseCompletes = modal.classList.contains('hidden') && !content.classList.contains('modal-closing');
|
||||
// Completing by animation must also remove the timer.
|
||||
open();
|
||||
await wait();
|
||||
states.reopenAfterCompletedAnimation = visible();
|
||||
close();
|
||||
await wait();
|
||||
states.fallbackCompletes = modal.classList.contains('hidden') && !content.classList.contains('modal-closing');
|
||||
return states;
|
||||
}, MODAL_ID);
|
||||
console.log(JSON.stringify(result));
|
||||
} finally { await browser.close(); }
|
||||
"""
|
||||
script = script.replace('WIDTH', str(width)).replace('MODAL_ID', json.dumps(modal_id))
|
||||
script = script.replace('__STYLES__', stylesheet_link_tags())
|
||||
result = subprocess.run(['node','--input-type=module','-e',script], capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
states = json.loads(result.stdout.strip())
|
||||
assert all(states.values()), states
|
||||
|
||||
|
||||
def test_modal_visibility_is_available_offline_before_first_open():
|
||||
script = r"""
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch({headless:true});
|
||||
try {
|
||||
const context = await browser.newContext({serviceWorkers:'allow'});
|
||||
const page = await context.newPage();
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
await page.goto(origin+'/static/js/storage.js');
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.register('/static/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
if(!navigator.serviceWorker.controller) await new Promise(resolve =>
|
||||
navigator.serviceWorker.addEventListener('controllerchange',resolve,{once:true}));
|
||||
const response = await caches.match('/static/js/modalVisibility.js');
|
||||
if(!response?.ok) throw new Error('Modal helper is missing from install-time precache');
|
||||
});
|
||||
await context.setOffline(true);
|
||||
const offline = await context.newPage();
|
||||
await offline.goto(origin+'/static/js/storage.js');
|
||||
const exports = await offline.evaluate(async () =>
|
||||
Object.keys(await import('/static/js/modalVisibility.js')).sort());
|
||||
assert.deepEqual(exports,['hideModal','showModal']);
|
||||
} finally { await browser.close(); }
|
||||
"""
|
||||
result = subprocess.run(['node','--input-type=module','-e',script], capture_output=True, text=True, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,81 @@
|
||||
"""An automatic fallback must not prevent resolving the configured default."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.js_modules import js_function_source
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PICKER = (ROOT / 'static/js/modelPicker.js').read_text()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
|
||||
@pytest.mark.parametrize('case', ['fallback', 'manual', 'default', 'session', 'manual-during-fetch', 'no-default'])
|
||||
def test_configured_default_and_selection_ownership(case):
|
||||
source = '\n'.join(js_function_source(name, PICKER) for name in [
|
||||
'_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel',
|
||||
])
|
||||
script = r'''
|
||||
const source = JSON.parse(process.argv[1]), mode = process.argv[2];
|
||||
let pending = {url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'};
|
||||
if (mode === 'manual' || mode === 'default') pending.source = mode;
|
||||
const initial = {...pending};
|
||||
const configured = {endpoint_url:pending.url,model:'preferred',endpoint_id:'shared'};
|
||||
let fetches = 0, updates = 0;
|
||||
const _deps = {getCurrentSessionId:()=> mode === 'session' ? 'existing' : null,
|
||||
getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}};
|
||||
const window = {modelsModule:{getCachedItems:()=>[{url:initial.url,models:['first','preferred'],endpoint_id:'shared'}]}};
|
||||
const localStorage = {setItem(){}};
|
||||
const API_BASE = 'http://app';
|
||||
let _defaultChatPickInFlight = false, _defaultPendingSeq = 0;
|
||||
function updateModelPicker(){updates++;}
|
||||
async function fetch(){fetches++; if(mode==='manual-during-fetch') pending={...initial,modelId:'chosen',source:'manual'};
|
||||
return {ok:true,json:async()=>mode==='no-default'?{}:configured};}
|
||||
// All helpers share the same lexical dependencies as the production function.
|
||||
eval(source.replace('async function _ensureDefaultPendingChat','globalThis.ensureDefault = async function'));
|
||||
await globalThis.ensureDefault();
|
||||
console.log(JSON.stringify({pending,fetches,updates,inFlight:_defaultChatPickInFlight}));
|
||||
'''
|
||||
# Script is a module so async fetch ordering is exercised without a browser shim.
|
||||
result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source), case],
|
||||
cwd=ROOT, capture_output=True, text=True, check=True, timeout=15)
|
||||
data = json.loads(result.stdout)
|
||||
assert data['inFlight'] is False
|
||||
if case == 'fallback':
|
||||
assert data['pending']['modelId'] == 'preferred'
|
||||
assert data['pending']['source'] == 'default'
|
||||
assert data['fetches'] == 1
|
||||
elif case == 'manual-during-fetch':
|
||||
assert data['pending']['modelId'] == 'chosen'
|
||||
assert data['pending']['source'] == 'manual'
|
||||
else:
|
||||
assert data['pending']['modelId'] == 'first'
|
||||
assert data['pending']['source'] == (case if case in {'manual', 'default'} else 'fallback')
|
||||
assert data['fetches'] == (1 if case == 'no-default' else 0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
|
||||
def test_cached_default_updates_visible_picker_after_fallback():
|
||||
names = ['_ensureDefaultPendingChat', '_ensureModelCacheForFallback', '_firstAvailableModel', '_modelExists', 'updateModelPicker']
|
||||
source = '\n'.join(js_function_source(name, PICKER).removeprefix('export ') for name in names)
|
||||
script = r'''
|
||||
let pending={url:'http://models/v1/chat/completions',modelId:'first',endpointId:'shared',source:'fallback'};
|
||||
const _deps={getCurrentSessionId:()=>null,getSessions:()=>[],getPendingChat:()=>pending,setPendingChat:value=>{pending=value;}};
|
||||
const label={textContent:''};
|
||||
const document={getElementById:id=>id==='model-picker-label'?label:null};
|
||||
const window={__odysseusDefaultChat:{endpoint_url:pending.url,endpoint_id:'shared',model:'preferred'},
|
||||
modelsModule:{getCachedItems:()=>[{url:pending.url,endpoint_id:'shared',models:['first','preferred']}]}};
|
||||
const localStorage={setItem(){}};
|
||||
let _defaultChatPickInFlight=false,_defaultPendingSeq=0,_autoSelectingDefault=false;
|
||||
function providerLogo(){return null;}function providerLabel(){return 'Fixture';}function _updateReasoningEffortUI(){}
|
||||
eval(JSON.parse(process.argv[1])+'\nupdateModelPicker();');
|
||||
console.log(JSON.stringify({pending,label:label.textContent}));
|
||||
'''
|
||||
result = subprocess.run(['node', '--input-type=module', '-e', script, json.dumps(source)],
|
||||
cwd=ROOT, capture_output=True, text=True, check=True, timeout=15)
|
||||
data = json.loads(result.stdout)
|
||||
assert data['pending']['modelId'] == 'preferred'
|
||||
assert 'preferred' in data['label']
|
||||
@@ -0,0 +1,103 @@
|
||||
"""History controllers must not dismiss reports the server failed to delete."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _run(outcomes, *, bulk=False, clear=False, statuses=None):
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node is required to execute research controllers")
|
||||
script = r"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const options = JSON.parse(process.argv[1]);
|
||||
const saved = new Map();
|
||||
const errors = [], calls = [];
|
||||
const context = {
|
||||
localStorage: {getItem:k=>saved.get(k)||null, setItem:(k,v)=>saved.set(k,v)},
|
||||
document: {getElementById:()=>null},
|
||||
uiModule: {showError:message=>errors.push(message)},
|
||||
window: {styledConfirm:async()=>true},
|
||||
fetch: async (url, opts) => {
|
||||
const id = url.split('/').pop();calls.push({id, method:opts.method, credentials:opts.credentials});
|
||||
const outcome = options.outcomes[id] ?? 200;
|
||||
if (outcome === 'network') throw new Error('scripted network failure');
|
||||
return {ok:outcome>=200 && outcome<300, status:outcome};
|
||||
},
|
||||
};
|
||||
vm.createContext(context);
|
||||
const jobs = fs.readFileSync('static/js/research/jobs.js','utf8');
|
||||
vm.runInContext(jobs.replace(/export /g, ''), context);
|
||||
vm.runInContext('globalThis.jobs = {getJobs,removeJob};', context);
|
||||
context.seed = Object.entries(options.statuses || {a:'done',b:'done',untouched:'done'}).map(([id,status])=>({id,status}));
|
||||
vm.runInContext('getJobs().push(...seed);', context);
|
||||
const panel = fs.readFileSync('static/js/research/panel.js','utf8');
|
||||
const start = panel.indexOf('function _updateHistoryBulkBar(');
|
||||
const end = panel.indexOf('function _renderJobs(', start);
|
||||
if (start < 0 || end < 0) throw new Error('history controller boundaries missing');
|
||||
vm.runInContext("_apiBase=''; let _historySelectMode=true; let _historySelectedIds=new Set(['a','b']); let _visibleHistoryIds=['a','b']; function _renderJobs(){};"+panel.slice(start,end), context);
|
||||
(async()=>{
|
||||
const command = options.clear ? '_clearHistory()' : options.bulk ? '_bulkDeleteHistory()' : "_deleteHistoryJobs(['a','b'])";
|
||||
await vm.runInContext(command, context);
|
||||
console.log(JSON.stringify({
|
||||
ids:vm.runInContext('getJobs().map(j=>j.id)',context),
|
||||
selected:vm.runInContext('[..._historySelectedIds]',context),
|
||||
selectionMode:vm.runInContext('_historySelectMode',context),
|
||||
dismissed:JSON.parse(saved.get('odysseus-research-dismissed')||'[]'),
|
||||
errors,calls,
|
||||
}));
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
"""
|
||||
args = dict(outcomes=outcomes, bulk=bulk, clear=clear, statuses=statuses)
|
||||
result = subprocess.run(["node", "-e", script, json.dumps(args)], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [403, 404, 500, 503, "network"])
|
||||
def test_failed_reports_remain_visible_and_undismissed(failure):
|
||||
result = _run({"a": failure, "b": failure})
|
||||
assert result["ids"] == ["a", "b", "untouched"]
|
||||
assert result["dismissed"] == []
|
||||
assert len(result["errors"]) == 1
|
||||
assert "2" in result["errors"][0]
|
||||
assert all(call["method"] == "DELETE" and call["credentials"] == "same-origin"
|
||||
for call in result["calls"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [500, "network"])
|
||||
def test_mixed_bulk_deletion_keeps_only_failed_selection_for_retry(failure):
|
||||
result = _run({"a": 200, "b": failure}, bulk=True)
|
||||
assert result["ids"] == ["b", "untouched"]
|
||||
assert result["dismissed"] == ["a"]
|
||||
assert result["selectionMode"] is True
|
||||
assert result["selected"] == ["b"]
|
||||
assert len(result["errors"]) == 1
|
||||
|
||||
|
||||
def test_successful_bulk_deletion_exits_selection():
|
||||
result = _run({"a": 200, "b": 200}, bulk=True)
|
||||
assert result["ids"] == ["untouched"]
|
||||
assert result["selectionMode"] is False
|
||||
assert result["selected"] == []
|
||||
assert result["dismissed"] == ["a", "b"]
|
||||
assert result["errors"] == []
|
||||
|
||||
|
||||
def test_local_failed_job_removal_needs_no_server_request():
|
||||
result = _run({"a": 500}, statuses={"a": "error", "b": "cancelled"})
|
||||
assert result["ids"] == []
|
||||
assert result["calls"] == []
|
||||
assert result["errors"] == []
|
||||
|
||||
|
||||
def test_clear_history_preserves_running_jobs_and_failed_deletions():
|
||||
result = _run({"a": 500}, clear=True, statuses={"a": "done", "b": "running"})
|
||||
assert result["ids"] == ["a", "b"]
|
||||
assert result["dismissed"] == []
|
||||
assert [call["id"] for call in result["calls"]] == ["a"]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""set_privileges rejects mistyped values instead of storing them (issue #6829)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_set_admin import _fresh_auth_manager
|
||||
|
||||
|
||||
@pytest.mark.parametrize("privs", [
|
||||
{"can_use_bash": "no"},
|
||||
{"can_use_bash": 1},
|
||||
{"max_messages_per_day": "abc"},
|
||||
{"max_messages_per_day": True},
|
||||
{"allowed_models": "gpt"},
|
||||
{"block_all_models": None},
|
||||
])
|
||||
def test_set_privileges_rejects_mistyped_values(tmp_path, privs):
|
||||
_, mgr = _fresh_auth_manager(tmp_path)
|
||||
mgr.create_user("bob", "pw-123456")
|
||||
before = dict(mgr.get_privileges("bob"))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
mgr.set_privileges("bob", privs)
|
||||
|
||||
assert mgr.get_privileges("bob") == before
|
||||
|
||||
|
||||
def test_set_privileges_accepts_well_typed_values(tmp_path):
|
||||
_, mgr = _fresh_auth_manager(tmp_path)
|
||||
mgr.create_user("bob", "pw-123456")
|
||||
|
||||
assert mgr.set_privileges("bob", {
|
||||
"can_use_bash": True,
|
||||
"max_messages_per_day": 25,
|
||||
"allowed_models": ["gpt"],
|
||||
"unknown_key": "ignored",
|
||||
}) is True
|
||||
|
||||
privs = mgr.get_privileges("bob")
|
||||
assert privs["can_use_bash"] is True
|
||||
assert privs["max_messages_per_day"] == 25
|
||||
assert privs["allowed_models"] == ["gpt"]
|
||||
assert "unknown_key" not in privs
|
||||
@@ -0,0 +1,112 @@
|
||||
"""The real Skills module must leave test results before opening its editor."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1440, 390])
|
||||
@pytest.mark.parametrize("mode", ["done", "running", "source"])
|
||||
def test_skill_editor_transitions(tmp_path, width, mode):
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node not installed")
|
||||
repo = Path(__file__).resolve().parents[1]
|
||||
index = BeautifulSoup((repo / "static/index.html").read_text(), "html.parser")
|
||||
modal = index.select_one("#memory-modal")
|
||||
modal["class"] = [value for value in modal.get("class", []) if value != "hidden"]
|
||||
for panel in modal.select("[data-memory-panel]"):
|
||||
classes = set(panel.get("class", []))
|
||||
if panel["data-memory-panel"] == "skills":
|
||||
classes.discard("hidden")
|
||||
else:
|
||||
classes.add("hidden")
|
||||
panel["class"] = sorted(classes)
|
||||
styles = "".join(str(node) for node in index.select('link[rel="stylesheet"]'))
|
||||
document = tmp_path / "skills.html"
|
||||
document.write_text(styles + str(modal) + str(index.select_one("#toast")), encoding="utf-8")
|
||||
script = r"""
|
||||
const fs=require('node:fs'), assert=require('node:assert/strict');
|
||||
let chromium;
|
||||
try { ({chromium}=require('playwright')); }
|
||||
catch(e) { if(e.code==='MODULE_NOT_FOUND')process.exit(77); throw e; }
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({headless:true});
|
||||
try{
|
||||
const origin=process.env.ODYSSEUS_TEST_STATIC_ORIGIN, mode=process.argv[3];
|
||||
const page=await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});
|
||||
page.setDefaultTimeout(4000);
|
||||
const source='---\nname: fixture-editor\nversion: "1.0"\n---\n\n## Procedure\nOriginal source';
|
||||
let markdown=source,posts=0,statusCalls=0;
|
||||
const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.route(origin+'/api/**',async route=>{
|
||||
const request=route.request(),path=new URL(request.url()).pathname;
|
||||
let data={};
|
||||
if(path==='/api/skills')data={skills:[{name:'fixture-editor',description:'Editor fixture',status:'draft',source:'user',confidence:0.9,procedure:['Original source']}]};
|
||||
else if(path.endsWith('/markdown')){
|
||||
if(request.method()==='POST'){posts++;markdown=request.postDataJSON().markdown;data={ok:true};}
|
||||
else data={markdown};
|
||||
}else if(path.endsWith('/test-status')){
|
||||
statusCalls++;
|
||||
const status=mode==='running'&&statusCalls===1?'running':'done';
|
||||
data={status,log:[{type:'say',text:'Completed fixture output'}],verdict:status==='done'?{verdict:'pass',confidence:0.95,summary:'Fixture verdict',issues:[]}:null};
|
||||
}else if(path.endsWith('/audit-all/status'))data={status:'none'};
|
||||
await route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
|
||||
});
|
||||
await page.goto(origin+'/static/js/storage.js');
|
||||
await page.setContent(fs.readFileSync(process.argv[1],'utf8'));
|
||||
await page.evaluate(async()=>{const skills=await import('/static/js/skills.js');await skills.loadSkills();});
|
||||
const card=page.locator('.skill-card[data-skill-name="fixture-editor"]');
|
||||
await card.locator('.skill-card-name').click();
|
||||
if(mode!=='source'){
|
||||
await card.getByRole('button',{name:'Test',exact:true}).click();
|
||||
if(mode==='running'){
|
||||
await card.getByText('…running (you can close this — it keeps going)',{exact:true}).waitFor();
|
||||
await card.locator('.skill-chevron-up').click();
|
||||
await card.locator('.skill-kebab-btn').click();
|
||||
await page.locator('.skill-kebab-menu:visible').getByRole('button',{name:'Edit',exact:true}).click();
|
||||
}else{
|
||||
await card.getByText('Fixture verdict',{exact:true}).waitFor();
|
||||
await card.locator('[data-act="edit"]').click();
|
||||
}
|
||||
}else await card.getByRole('button',{name:'Edit',exact:true}).click();
|
||||
const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});
|
||||
assert.equal(await editor.inputValue(),source);
|
||||
assert.equal(await card.locator('.skill-test').count(),0);
|
||||
assert(await card.locator('.doclib-card-expanded-actions button:visible').evaluateAll(buttons=>buttons.every(button=>{
|
||||
const bounds=button.getBoundingClientRect(),card=button.closest('.skill-card').getBoundingClientRect();
|
||||
return bounds.left>=card.left&&bounds.right<=card.right;
|
||||
})), 'Editor actions must fit inside the card');
|
||||
await editor.fill(source+'\nCancelled draft');
|
||||
if(mode==='running'){
|
||||
await page.waitForFunction(()=>document.querySelector('.skill-card')?._testPoll===null);
|
||||
assert.equal(await editor.inputValue(),source+'\nCancelled draft');
|
||||
}
|
||||
await card.getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
assert.equal(await card.locator('.skill-md-editor').count(),0);
|
||||
assert.equal(posts,0);assert.equal(markdown,source);
|
||||
assert.equal(await card.locator('.skill-md-pre').innerText(),source);
|
||||
if(mode!=='source'){
|
||||
await card.getByRole('button',{name:'Test',exact:true}).click();
|
||||
await card.getByText('Fixture verdict',{exact:true}).waitFor();
|
||||
await card.locator('[data-act="edit"]').click();
|
||||
}else await card.getByRole('button',{name:'Edit',exact:true}).click();
|
||||
await editor.fill(source+'\nSaved change');
|
||||
await card.getByRole('button',{name:'Save',exact:true}).click();
|
||||
await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
|
||||
assert.equal(posts,1);assert.equal(markdown,source+'\nSaved change');
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log(JSON.stringify({mode,width:process.argv[2],posts}));
|
||||
}finally{await browser.close();}
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script, str(document), str(width), mode],
|
||||
cwd=repo, env=os.environ.copy(), capture_output=True,
|
||||
text=True, timeout=45)
|
||||
if result.returncode == 77:
|
||||
pytest.skip("playwright not installed")
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert json.loads(result.stdout)["posts"] == 1
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Research reports preserve TeX for the shared, vendored math renderer."""
|
||||
from bs4 import BeautifulSoup
|
||||
import pytest
|
||||
|
||||
from src.visual_report import _md_to_html, generate_visual_report
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,tex,display", [
|
||||
(r"Value $x_i^2$.", "x_i^2", "false"),
|
||||
(r"Value \(x_i^2\).", "x_i^2", "false"),
|
||||
(r"Value $$\sum_i x_i$$.", r"\sum_i x_i", "true"),
|
||||
(r"Value \[\sum_i x_i\].", r"\sum_i x_i", "true"),
|
||||
("$$a_i\n\nb_i$$", "a_i\n\nb_i", "true"),
|
||||
("\\[a_i\n\nb_i\\]", "a_i\n\nb_i", "true"),
|
||||
])
|
||||
def test_math_source_survives_markdown(source, tex, display):
|
||||
soup = BeautifulSoup(_md_to_html(source), "html.parser")
|
||||
pending = soup.select(".ody-math-pending")
|
||||
assert len(pending) == 1
|
||||
assert pending[0].get_text() == tex
|
||||
assert pending[0]["data-display"] == display
|
||||
assert pending[0].find("em") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [
|
||||
"`$x_i$`", "```text\n$x_i$\n```", "~~~text\n$x_i$\n~~~",
|
||||
" $x_i$", "<code>$x_i$</code>", "<pre>$x_i$</pre>",
|
||||
"Prices range from $5 to $10.", "An escaped \\$5 costs less than $10.",
|
||||
])
|
||||
def test_code_and_currency_are_not_math(source):
|
||||
soup = BeautifulSoup(_md_to_html(source), "html.parser")
|
||||
assert not soup.select(".ody-math-pending")
|
||||
assert "$" in soup.get_text()
|
||||
|
||||
|
||||
def test_html_attributes_are_not_rewritten_as_math():
|
||||
soup = BeautifulSoup(_md_to_html('<a title="$literal$">Value $x_i$</a>'), "html.parser")
|
||||
assert soup.a["title"] == "$literal$"
|
||||
assert soup.select_one("a .ody-math-pending").get_text() == "x_i"
|
||||
|
||||
|
||||
def test_link_destinations_stay_literal_while_link_labels_can_contain_math():
|
||||
soup = BeautifulSoup(_md_to_html('[$x_i$](https://example.com/$literal$)'), "html.parser")
|
||||
assert soup.a["href"] == "https://example.com/$literal$"
|
||||
assert soup.select_one("a .ody-math-pending").get_text() == "x_i"
|
||||
|
||||
|
||||
def test_math_source_is_inert_and_report_sanitizer_stays_active():
|
||||
soup = BeautifulSoup(_md_to_html(
|
||||
r'$\text{<img src=x onerror="alert(1)">}$'
|
||||
'\n\n<script>alert(2)</script><a href="javascript:alert(3)">link</a>'
|
||||
), "html.parser")
|
||||
assert soup.select_one(".ody-math-pending").get_text() == r'\text{<img src=x onerror="alert(1)">}'
|
||||
assert soup.find("img") is None
|
||||
assert soup.find("script") is None
|
||||
assert not soup.select('a[href^="javascript:"]')
|
||||
|
||||
|
||||
def test_heading_math_reaches_body_and_toc_without_changing_anchor_contract():
|
||||
soup = BeautifulSoup(generate_visual_report(
|
||||
"Formula report", "# Formula report\n\n## Formula $x_i$\n\nValue $x_i$.",
|
||||
session_id="rp-math",
|
||||
), "html.parser")
|
||||
link = soup.select_one(".toc-sidebar nav a")
|
||||
heading = soup.find(id=link["href"][1:])
|
||||
assert heading["id"] == "formula-xi"
|
||||
for node in (link, heading):
|
||||
assert node.select_one(".ody-math-pending").get_text() == "x_i"
|
||||
assert soup.select_one("#mobile-section-select option").get_text() == "Formula x_i"
|
||||
|
||||
|
||||
def test_unclosed_display_math_stays_readable():
|
||||
soup = BeautifulSoup(_md_to_html("$$unfinished\n\nA later paragraph."), "html.parser")
|
||||
assert not soup.select(".ody-math-pending")
|
||||
assert "unfinished" in soup.get_text()
|
||||
assert "A later paragraph." in soup.get_text()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Exercise generated report HTML with the real vendored KaTeX in Chromium."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from src.visual_report import generate_visual_report
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1440, 390])
|
||||
@pytest.mark.parametrize("mode", ["math", "plain", "blocked"])
|
||||
def test_report_math_in_browser(tmp_path, width, mode):
|
||||
if not shutil.which("node"):
|
||||
pytest.skip("node not installed")
|
||||
markdown = (
|
||||
"# Math report\n\n" + r"## Formula $x_i$" + "\n\n"
|
||||
+ r"Inline \(x_i^2\) and $a < b$." + "\n\n"
|
||||
+ r"\[\sum_{i=1}^{3} x_i\]" + "\n\n"
|
||||
+ r"Invalid: $\frac{1}$. Unsafe: $\href{javascript:alert(1)}{bad}$."
|
||||
+ "\n\n`$literal$` costs $5 to $10."
|
||||
+ "\n\n$$" + "+".join(["x_i"] * 30) + "$$"
|
||||
) if mode != "plain" else "## Findings\n\nNo math here."
|
||||
document = tmp_path / "report.html"
|
||||
document.write_text(generate_visual_report("Math report", markdown), encoding="utf-8")
|
||||
script = r"""
|
||||
const fs = require('node:fs'), assert = require('node:assert/strict');
|
||||
let chromium;
|
||||
try { ({chromium} = require('playwright')); }
|
||||
catch (error) { if (error.code === 'MODULE_NOT_FOUND') process.exit(77); throw error; }
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true});
|
||||
try {
|
||||
const page = await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const errors = [], requests = [], failures = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('request', r => requests.push(r.url()));
|
||||
page.on('requestfailed', r => failures.push(r.url()));
|
||||
page.on('dialog', async d => { errors.push('Unexpected dialog'); await d.dismiss(); });
|
||||
const mode = process.argv[3];
|
||||
if (mode === 'blocked') await page.route('**/static/lib/katex/katex.min.js', r => r.abort());
|
||||
await page.route(origin + '/report-math-test', r => r.fulfill({
|
||||
contentType:'text/html', body:fs.readFileSync(process.argv[1], 'utf8')
|
||||
}));
|
||||
await page.goto(origin + '/report-math-test');
|
||||
if (mode === 'math') {
|
||||
await page.locator('.content .katex-display').first().waitFor({state:'attached'});
|
||||
await page.waitForFunction(() => !document.querySelector('.ody-math-pending'));
|
||||
assert.equal(await page.locator('.toc-sidebar .katex').count(), 1);
|
||||
assert.equal(await page.locator('.content .katex-error').count(), 1);
|
||||
assert.equal(await page.locator('.content code').innerText(), '$literal$');
|
||||
assert((await page.locator('.content').innerText()).includes('$5 to $10'));
|
||||
assert.equal(await page.locator('a[href^="javascript:"]').count(), 0);
|
||||
assert.equal(requests.filter(u => u.endsWith('/static/lib/katex/katex.min.js')).length, 1);
|
||||
assert.equal(requests.filter(u => u.endsWith('/static/lib/katex/katex.min.css')).length, 1);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
assert.deepEqual(failures, []);
|
||||
} else if (mode === 'blocked') {
|
||||
await page.waitForFunction(() => performance.getEntriesByType('resource').some(e => e.name.includes('katex.min.js')));
|
||||
assert((await page.locator('.content .ody-math-pending').first().innerText()).includes('x_i'));
|
||||
assert.equal(await page.locator('.katex').count(), 0);
|
||||
} else {
|
||||
await page.waitForLoadState('networkidle');
|
||||
assert.equal(requests.some(u => u.includes('/static/lib/katex/')), false);
|
||||
assert.equal(requests.some(u => u.includes('/static/js/markdown.js')), false);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
assert(requests.every(u => u.startsWith(origin + '/')), JSON.stringify(requests));
|
||||
console.log(JSON.stringify({mode, width:process.argv[2], requests:requests.length}));
|
||||
} finally { await browser.close(); }
|
||||
})().catch(e => { console.error(e); process.exitCode=1; });
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script, str(document), str(width), mode],
|
||||
cwd=Path(__file__).resolve().parents[1], env=os.environ.copy(),
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if result.returncode == 77:
|
||||
pytest.skip("playwright not installed")
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert json.loads(result.stdout)["mode"] == mode
|
||||
Reference in New Issue
Block a user