mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-23 15:22:20 +02:00
Compare commits
10
Commits
c12b8ab6c9
...
7e5db9a3c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e5db9a3c6 | ||
|
|
2f246c7779 | ||
|
|
8ec27fd903 | ||
|
|
b57989f08c | ||
|
|
91bba117c1 | ||
|
|
4c82e4a172 | ||
|
|
b899095f18 | ||
|
|
888e25624f | ||
|
|
c062c27648 | ||
|
|
93ec7cbb52 |
@@ -0,0 +1,27 @@
|
||||
"""Helpers for keeping sensitive data out of logs.
|
||||
|
||||
Endpoint URLs configured by admins can embed credentials in the userinfo
|
||||
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
|
||||
raw leaks those secrets, so route/diagnostic logs run URLs through
|
||||
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
|
||||
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def redact_url(url: str) -> str:
|
||||
"""Return a URL safe for logs by removing userinfo and query/fragment.
|
||||
|
||||
Keeps scheme, host, port and path so logs stay useful for debugging.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
host = parsed.hostname or ""
|
||||
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
|
||||
host = f"[{host}]"
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||||
except Exception:
|
||||
return "<endpoint>"
|
||||
+14
-1
@@ -15,7 +15,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
|
||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||
Use that for the first login, then change it in **Settings**.
|
||||
|
||||
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
|
||||
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
|
||||
pull request guidelines.
|
||||
|
||||
### Docker (recommended)
|
||||
@@ -250,6 +250,19 @@ python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||
3.11+ version) for the venv step.
|
||||
|
||||
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
|
||||
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
|
||||
alone leaves the native Windows server on loopback. Pass the launcher's
|
||||
`-BindHost` flag instead:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
|
||||
```
|
||||
|
||||
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
|
||||
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
|
||||
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
|
||||
|
||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||
model downloads and the agent shell tool, also install
|
||||
|
||||
@@ -29,6 +29,7 @@ from routes.document_helpers import _owner_session_filter
|
||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||
from core.database import Document as DBDocument, ModelEndpoint
|
||||
from core.log_safety import redact_url
|
||||
from routes.research_routes import _resolve_research_endpoint
|
||||
from routes.model_routes import _visible_models
|
||||
from routes.chat_helpers import (
|
||||
@@ -930,7 +931,7 @@ def setup_chat_routes(
|
||||
if effective_do_research:
|
||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={redact_url(_r_ep)}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||
|
||||
# Clarification round: only for very short/vague queries on first research message.
|
||||
# Skip in compare mode — each pane is a fresh session, so every one would
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse, urlunparse
|
||||
|
||||
from core.log_safety import redact_url
|
||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -689,15 +690,24 @@ def _delete_contact(uid: str) -> bool:
|
||||
url = _resolve_resource_url(uid)
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
r = httpx.delete(url, auth=auth, timeout=10)
|
||||
if r.status_code in (200, 204):
|
||||
_contact_cache["fetched_at"] = None
|
||||
return True
|
||||
if r.status_code == 404:
|
||||
# Resource not found at the resolved URL. With href resolution
|
||||
# this should be rare (genuinely already deleted). Invalidate
|
||||
# the cache and report success so the UI doesn't keep a ghost.
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone")
|
||||
if r.status_code in (200, 204, 404):
|
||||
# Invalidate cache so the next fetch sees the server truth.
|
||||
_contact_cache["fetched_at"] = None
|
||||
# Verify: force a fresh fetch and check the UID is actually gone.
|
||||
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
|
||||
# lives at a different resource URL — the DELETE missed it but
|
||||
# we'd silently report success. This check catches that.
|
||||
fresh = _fetch_contacts(force=True)
|
||||
still_there = any(c.get("uid") == uid for c in fresh)
|
||||
if still_there:
|
||||
logger.warning(
|
||||
f"CardDAV DELETE reported success for {uid} "
|
||||
f"but UID still present after re-fetch — "
|
||||
f"resource URL may differ from {redact_url(url)}"
|
||||
)
|
||||
return False
|
||||
if r.status_code == 404:
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
|
||||
return True
|
||||
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from core.database import Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,6 +79,8 @@ def _verify_doc_owner(db, doc: Document, user: str):
|
||||
the session join for any not-yet-backfilled legacy row.
|
||||
"""
|
||||
if user is None:
|
||||
if _auth_disabled():
|
||||
return # Single-user / no-auth mode: allow access
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if doc.owner is not None:
|
||||
if doc.owner != user:
|
||||
@@ -104,7 +107,6 @@ def _owner_session_filter(q, user):
|
||||
by the time this filter is live there are no NULL-owner rows to leak;
|
||||
we therefore match the owner strictly for authenticated callers."""
|
||||
if not user:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
if user == "" or _auth_disabled():
|
||||
return q
|
||||
return q.filter(False)
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
|
||||
from sqlalchemy import case, func, or_
|
||||
from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -388,7 +388,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not user:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if not _auth_disabled():
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v2 review HIGH-9: raise 403 explicitly when the caller
|
||||
# can't see this session, instead of returning [] which the
|
||||
# UI treats identically to "no docs" and silently masks
|
||||
|
||||
+3
-14
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import StreamingResponse
|
||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||
from core.log_safety import redact_url as _redact_url_for_log
|
||||
from core.middleware import require_admin
|
||||
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
||||
from src.tls_overrides import llm_verify
|
||||
@@ -582,18 +583,6 @@ def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
|
||||
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
|
||||
def _redact_url_for_log(url: str) -> str:
|
||||
"""Return a URL safe for logs by removing userinfo and query/fragment."""
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||||
except Exception:
|
||||
return "<endpoint>"
|
||||
|
||||
|
||||
def _is_discovery_only_provider(provider: str) -> bool:
|
||||
return provider == "chatgpt-subscription"
|
||||
|
||||
@@ -810,9 +799,9 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||
except Exception as e:
|
||||
if api_key:
|
||||
logger.warning(f"Failed to probe {url} with API key: {e}")
|
||||
logger.warning("Failed to probe %s with API key: %s", _redact_url_for_log(url), e)
|
||||
return []
|
||||
logger.warning(f"Failed to probe {url}: {e}")
|
||||
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||
|
||||
# Older Ollama builds and some proxies expose native /api/tags even when
|
||||
# the OpenAI-compatible /v1/models path is unavailable.
|
||||
|
||||
@@ -335,10 +335,11 @@ async def dispatch_reminder(
|
||||
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
||||
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||||
logger.info(
|
||||
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
|
||||
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
|
||||
f"from={from_addr!r} recipient={recipient!r} "
|
||||
f"account_name={cfg.get('account_name')!r}"
|
||||
"dispatch_reminder[email] note_id=%s owner=%r "
|
||||
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
||||
note_id, owner,
|
||||
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
||||
bool(from_addr), bool(recipient),
|
||||
)
|
||||
missing = []
|
||||
if not cfg.get("smtp_host"):
|
||||
|
||||
@@ -538,6 +538,32 @@ def _powershell_exe():
|
||||
path so we don't depend on a particular PATH ordering."""
|
||||
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||
|
||||
def _powershell_encoded_for_ssh(script: str):
|
||||
"""Run a PowerShell script on a remote Windows host over SSH.
|
||||
|
||||
Nested quotes in powershell -Command break when passed through Windows
|
||||
OpenSSH's cmd wrapper; -EncodedCommand avoids that.
|
||||
"""
|
||||
import base64
|
||||
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||
return _run(f"powershell -NoProfile -EncodedCommand {encoded}")
|
||||
|
||||
|
||||
def _probe_remote_platform():
|
||||
"""Best-effort OS detection over SSH when the caller didn't pass platform."""
|
||||
out = _run("echo %OS%")
|
||||
if out and "Windows_NT" in out:
|
||||
return "windows"
|
||||
uname = (_run(["uname", "-s"]) or "").strip().lower()
|
||||
if uname == "darwin":
|
||||
# Mac uses the linux detection path (_detect_apple_silicon over SSH).
|
||||
return "linux"
|
||||
if uname == "linux":
|
||||
out = _run("test -d /data/data/com.termux && echo termux || echo linux")
|
||||
if out and "termux" in out:
|
||||
return "termux"
|
||||
return "linux"
|
||||
|
||||
|
||||
def _detect_windows():
|
||||
"""Detect Windows hardware via PowerShell/WMI.
|
||||
@@ -600,9 +626,8 @@ def _detect_windows():
|
||||
"""
|
||||
)
|
||||
if _remote_host:
|
||||
# Remote: ship a single command string over SSH. The remote shell parses
|
||||
# the quoting; PowerShell on the far side runs the -Command payload.
|
||||
out = _run(f'powershell -Command "{ps_cmd}"')
|
||||
# Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
|
||||
out = _powershell_encoded_for_ssh(ps_cmd.strip())
|
||||
else:
|
||||
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
||||
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
|
||||
@@ -773,6 +798,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"""
|
||||
global _remote_host, _remote_port, _remote_platform
|
||||
|
||||
if host and not platform:
|
||||
_remote_host = host
|
||||
_remote_port = ssh_port or None
|
||||
platform = _probe_remote_platform()
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
|
||||
cache_key = _cache_key(host, ssh_port, platform)
|
||||
now = time.time()
|
||||
if not fresh and cache_key in _cache_by_host:
|
||||
@@ -793,8 +825,8 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
# If Windows detection failed, return error
|
||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
||||
# 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_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
|
||||
+24
-6
@@ -3215,9 +3215,12 @@ async def stream_agent_loop(
|
||||
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
||||
)
|
||||
|
||||
# ask_user: the agent posed a multiple-choice question. Emit it so the
|
||||
# frontend renders clickable options, then end the turn (below) and
|
||||
# wait — the user's pick becomes the next message.
|
||||
# ask_user: remember the payload now, but emit the interactive event
|
||||
# only *after* tool_output below. Emitting it before tool_output let
|
||||
# the subsequent tool-card rewrite/scroll push the choices out of
|
||||
# view. The payload is also copied into the persisted tool event so
|
||||
# history reload can reconstruct an unanswered card.
|
||||
_pending_ask_user_event = None
|
||||
if "ask_user" in result:
|
||||
# The question lives in the tool args. ChatMessage.to_dict()
|
||||
# replays only role+content to the model next turn — tool_event
|
||||
@@ -3232,9 +3235,7 @@ async def stream_agent_loop(
|
||||
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
||||
full_response += _auq_delta
|
||||
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
|
||||
)
|
||||
_pending_ask_user_event = _auq
|
||||
_awaiting_user = True
|
||||
|
||||
# update_plan: agent wrote back to the plan (ticked a step / revised).
|
||||
@@ -3289,6 +3290,10 @@ async def stream_agent_loop(
|
||||
|
||||
# Emit tool_output (include ui_event data if present)
|
||||
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
||||
if _pending_ask_user_event:
|
||||
# Keep enough state in the streamed tool result for alternate
|
||||
# clients to render the prompt without depending on event order.
|
||||
tool_output_data["ask_user"] = _pending_ask_user_event
|
||||
if "ui_event" in result:
|
||||
tool_output_data["ui_event"] = result["ui_event"]
|
||||
for k in (
|
||||
@@ -3319,6 +3324,14 @@ async def stream_agent_loop(
|
||||
tool_output_data["diff"] = result["diff"]
|
||||
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
||||
|
||||
# This must be the final UI event for ask_user: the frontend appends
|
||||
# the card below the now-settled tool node and cancels any between-
|
||||
# round spinner. The turn ends after the current tool batch.
|
||||
if _pending_ask_user_event:
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "ask_user", "data": _pending_ask_user_event})}\n\n'
|
||||
)
|
||||
|
||||
# Native document tools open in the editor + carry the REAL doc id.
|
||||
# Emit a doc_update so the frontend opens/activates it and sends it
|
||||
# back as active_doc_id next turn (otherwise the agent can't "see"
|
||||
@@ -3376,6 +3389,11 @@ async def stream_agent_loop(
|
||||
# this the diff shows live but vanishes from saved history.
|
||||
if result.get("diff"):
|
||||
tool_event["diff"] = result["diff"]
|
||||
if _pending_ask_user_event:
|
||||
# Persist the structured question with the tool event. On a
|
||||
# reload, chatRenderer can restore the card; a later user
|
||||
# message removes it as answered.
|
||||
tool_event["ask_user"] = _pending_ask_user_event
|
||||
tool_events.append(tool_event)
|
||||
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
||||
_effectful_used = True
|
||||
|
||||
@@ -1667,7 +1667,7 @@ class TaskScheduler:
|
||||
msg["X-Odysseus-Ref"] = str(task.id)
|
||||
msg.set_content(result or "")
|
||||
_send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30)
|
||||
logger.info("Task %s emailed result to %s (%sb)", task.id, to_addr, len(result or ""))
|
||||
logger.info("Task %s emailed result (recipient_set=%s, %sb)", task.id, bool(to_addr), len(result or ""))
|
||||
except Exception as e:
|
||||
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
|
||||
raise
|
||||
@@ -2029,7 +2029,7 @@ class TaskScheduler:
|
||||
# silent SMTP failure is easier to spot in the logs.
|
||||
logger.info(
|
||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
||||
f"(to={recipient or '<unset>'}, body={body_len}b, reply={stdout[:200]!r})"
|
||||
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
||||
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||
"search_chats": "Search past session transcripts across chats.",
|
||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||
|
||||
+8
-2
@@ -467,7 +467,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "2-6 mutually exclusive choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
|
||||
"description": "2-6 choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"required": ["label"]
|
||||
}
|
||||
},
|
||||
"multi": {"type": "boolean", "description": "Set true to let the user select multiple options instead of one. Default false."}
|
||||
"multi": {"type": "boolean", "description": "Set true ONLY when the question explicitly allows choosing more than one option. Otherwise omit it or set false. Default false."}
|
||||
},
|
||||
"required": ["question", "options"]
|
||||
}
|
||||
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "ask_teacher":
|
||||
content = args.get("model", "auto") + "\n" + args.get("problem", "")
|
||||
elif tool_type == "ask_user":
|
||||
# Keep user-facing labels readable in the tool trace. The outer SSE
|
||||
# JSON encoder will escape them for transport and JSON.parse restores
|
||||
# them once; pre-escaping here caused literal ``\u00f1`` sequences to
|
||||
# remain visible in the debug panel.
|
||||
content = json.dumps(args, ensure_ascii=False)
|
||||
else:
|
||||
content = json.dumps(args)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||
_trashIcon, _moreIcon, _bellIcon,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
|
||||
_calReadableTextColor,
|
||||
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
||||
} from './calendar/utils.js';
|
||||
@@ -413,8 +413,8 @@ function _calEventFg(ev) {
|
||||
// Returns '' for normal solid-color events.
|
||||
function _calItemBgStyle(ev) {
|
||||
if (!_isCalBgImage(ev.color)) return '';
|
||||
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`;
|
||||
const url = _calBgImageUrl(ev.color);
|
||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`;
|
||||
}
|
||||
|
||||
function _todayCount() {
|
||||
@@ -1260,8 +1260,8 @@ async function _renderWeek() {
|
||||
// events keep the original tinted treatment.
|
||||
let bgDecl;
|
||||
if (_isCalBgImage(ev.color)) {
|
||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`;
|
||||
const _url = _calBgImageUrl(ev.color);
|
||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`;
|
||||
} else {
|
||||
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
||||
}
|
||||
@@ -2853,7 +2853,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
let bg;
|
||||
if (isCustom) {
|
||||
const url = _calBgImageUrl(cur);
|
||||
bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT;
|
||||
bg = url ? `center/cover no-repeat url('${_cssUrlEscape(url)}')` : _CAL_CUSTOM_GRADIENT;
|
||||
} else {
|
||||
bg = c.hex || 'var(--border)';
|
||||
}
|
||||
@@ -2928,7 +2928,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
// stays readable. Chrome accent falls back to the theme accent.
|
||||
const url = _calBgImageUrl(hex);
|
||||
_formCard.style.setProperty('--ev-color', 'var(--accent)');
|
||||
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`;
|
||||
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${_cssUrlEscape(url)}')`;
|
||||
_formCard.style.backgroundSize = 'cover';
|
||||
_formCard.style.backgroundPosition = 'center';
|
||||
_formCard.classList.add('cal-form-bg-image');
|
||||
@@ -2950,7 +2950,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
if (!url) return;
|
||||
const sentinel = 'bg:' + url;
|
||||
dot.dataset.color = sentinel;
|
||||
dot.style.background = `center/cover no-repeat url('${url}')`;
|
||||
dot.style.background = `center/cover no-repeat url('${_cssUrlEscape(url)}')`;
|
||||
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
|
||||
dot.classList.add('active');
|
||||
_applyFormTint(sentinel);
|
||||
|
||||
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
|
||||
return _isCalBgImage(c) ? c.slice(3) : '';
|
||||
}
|
||||
|
||||
// Escape a value for safe embedding inside a single-quoted CSS `url('...')`.
|
||||
// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the
|
||||
// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we
|
||||
// add for `'` and let the value break out of the string (CodeQL
|
||||
// js/incomplete-sanitization). `"` is percent-encoded for good measure.
|
||||
export function _cssUrlEscape(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '%22');
|
||||
}
|
||||
|
||||
// Returns a value safe to drop into `style="background:..."`. Falls back to
|
||||
// the calendar default for bg-image events in spots where an image would be
|
||||
// too small to render usefully (small grid dots, multi-day bars).
|
||||
export function _calBgCss(c, fallback) {
|
||||
if (_isCalBgImage(c)) {
|
||||
const u = _calBgImageUrl(c);
|
||||
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)');
|
||||
return u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)');
|
||||
}
|
||||
return c || fallback || 'var(--accent)';
|
||||
}
|
||||
|
||||
+13
-142
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
|
||||
import chatStream from './chatStream.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import presetsModule from './presets.js';
|
||||
import fileHandlerModule from './fileHandler.js';
|
||||
@@ -2321,148 +2320,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
} else if (json.type === 'ask_user') {
|
||||
if (_isBg) continue;
|
||||
// The agent posed a multiple-choice question; the turn has ended.
|
||||
// Render clickable options at the bottom of the history. The
|
||||
// user's pick is sent as the next message and the agent resumes.
|
||||
// Use the shared history renderer so the live and restored
|
||||
// versions have identical behavior.
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
const _aq = json.data || {};
|
||||
const _opts = Array.isArray(_aq.options) ? _aq.options : [];
|
||||
if (_aq.question && _opts.length) {
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
// Drop any prior unanswered card so only the latest shows.
|
||||
chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ask-user-card';
|
||||
const multi = !!_aq.multi;
|
||||
// Group the choices for assistive tech and label the group with
|
||||
// the question (set below); make the card focusable so it can be
|
||||
// moved to when it appears.
|
||||
card.setAttribute('role', 'group');
|
||||
card.tabIndex = -1;
|
||||
// Render any emoji in agent-supplied text through the app's
|
||||
// pipeline: escape, then svgify to monochrome theme-tinted
|
||||
// glyphs (project rule: never colorful emoji; respects the
|
||||
// "Text-only Emojis" setting like the rest of the chat).
|
||||
const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
|
||||
|
||||
// Header row holds the close (×) to dismiss the affordances and
|
||||
// just type a reply instead.
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ask-user-head';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const mi = uiModule.el('message');
|
||||
if (mi) mi.focus();
|
||||
});
|
||||
head.appendChild(closeBtn);
|
||||
card.appendChild(head);
|
||||
|
||||
// Render the question inside the card so it's self-contained:
|
||||
// some models call ask_user without first narrating the question
|
||||
// as assistant text, in which case the card would otherwise show
|
||||
// bare options with no prompt.
|
||||
if (_aq.question) {
|
||||
const q = document.createElement('div');
|
||||
q.className = 'ask-user-question';
|
||||
q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
q.innerHTML = _emo(_aq.question);
|
||||
card.appendChild(q);
|
||||
// Label the choice group with the question for screen readers.
|
||||
card.setAttribute('aria-labelledby', q.id);
|
||||
} else {
|
||||
card.setAttribute('aria-label', 'Question from the assistant');
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ask-user-options';
|
||||
card.appendChild(list);
|
||||
|
||||
const _send = (text) => {
|
||||
if (!text) return;
|
||||
// Remove the card once answered — the choice is sent as a
|
||||
// normal user message (and the question persists as the
|
||||
// assistant text above), so the affordances are spent.
|
||||
card.remove();
|
||||
const mi = uiModule.el('message');
|
||||
if (mi) mi.value = text;
|
||||
const sb = document.querySelector('.send-btn');
|
||||
if (sb) sb.click();
|
||||
};
|
||||
|
||||
_opts.forEach((opt, i) => {
|
||||
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||
if (!label) return;
|
||||
const descr = (opt && opt.description) ? String(opt.description) : '';
|
||||
const row = document.createElement(multi ? 'label' : 'button');
|
||||
row.className = 'ask-user-option';
|
||||
if (multi) {
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = label;
|
||||
row.appendChild(cb);
|
||||
}
|
||||
const txt = document.createElement('span');
|
||||
txt.className = 'ask-user-option-label';
|
||||
txt.innerHTML = _emo(label);
|
||||
row.appendChild(txt);
|
||||
if (descr) {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'ask-user-option-desc';
|
||||
d.innerHTML = _emo(descr);
|
||||
row.appendChild(d);
|
||||
}
|
||||
if (!multi) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => _send(label));
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
// Free-text "Other" — type a custom answer + send (Enter or →).
|
||||
const other = document.createElement('div');
|
||||
other.className = 'ask-user-other';
|
||||
const otherInput = document.createElement('input');
|
||||
otherInput.type = 'text';
|
||||
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||
const otherSend = document.createElement('button');
|
||||
otherSend.type = 'button';
|
||||
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||
otherSend.setAttribute('aria-label', 'Send answer');
|
||||
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||
const _submit = () => {
|
||||
const free = otherInput.value.trim();
|
||||
if (multi) {
|
||||
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
|
||||
if (free) picked.push(free);
|
||||
if (picked.length) _send(picked.join(', '));
|
||||
} else if (free) {
|
||||
_send(free);
|
||||
}
|
||||
};
|
||||
otherSend.addEventListener('click', _submit);
|
||||
otherInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
_submit();
|
||||
}
|
||||
});
|
||||
other.appendChild(otherInput);
|
||||
other.appendChild(otherSend);
|
||||
card.appendChild(other);
|
||||
|
||||
chatBox.appendChild(card);
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
// Move focus to the card so keyboard/screen-reader users land on
|
||||
// the question + choices when it appears.
|
||||
try { card.focus(); } catch (_) {}
|
||||
}
|
||||
chatRenderer.renderAskUserCard(json.data || {});
|
||||
|
||||
} else if (json.type === 'plan_update') {
|
||||
if (_isBg) continue;
|
||||
@@ -5019,7 +4881,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!header) return;
|
||||
const node = header.closest('.agent-thread-node');
|
||||
if (!node) return;
|
||||
node.classList.toggle('open');
|
||||
const opened = node.classList.toggle('open');
|
||||
if (opened) {
|
||||
// Expanding the final tool trace can push a pending ask_user card below
|
||||
// the viewport. Keep that immediately-adjacent prompt visible.
|
||||
const thread = node.closest('.agent-thread');
|
||||
const pendingCard = thread?.nextElementSibling;
|
||||
if (pendingCard?.classList.contains('ask-user-card')) {
|
||||
requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
|
||||
}
|
||||
}
|
||||
});
|
||||
window.__odysseus_thread_click_bound = true;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import { providerLogo, providerLabel } from './providers.js';
|
||||
import settingsModule from './settings.js';
|
||||
@@ -1974,6 +1975,142 @@ export function displayMetrics(messageElement, metrics) {
|
||||
if (uiModule) uiModule.scrollHistory();
|
||||
}
|
||||
|
||||
/** Remove any unanswered multiple-choice cards currently in the chat. */
|
||||
export function removeAskUserCards(root) {
|
||||
const scope = root || document.getElementById('chat-history') || document;
|
||||
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an ask_user payload as a durable choice card.
|
||||
*
|
||||
* This lives in the history renderer rather than the streaming loop so the
|
||||
* same UI can be used both for a live SSE event and for a persisted tool event
|
||||
* after a session reload.
|
||||
*/
|
||||
export function renderAskUserCard(payload, options) {
|
||||
const aq = payload || {};
|
||||
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||
|
||||
const renderOptions = options || {};
|
||||
removeAskUserCards(chatBox);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ask-user-card';
|
||||
card.setAttribute('role', 'group');
|
||||
card.tabIndex = -1;
|
||||
const multi = !!aq.multi;
|
||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ask-user-head';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.focus();
|
||||
});
|
||||
head.appendChild(closeBtn);
|
||||
card.appendChild(head);
|
||||
|
||||
const question = document.createElement('div');
|
||||
question.className = 'ask-user-question';
|
||||
question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
question.innerHTML = emojiText(aq.question);
|
||||
card.appendChild(question);
|
||||
card.setAttribute('aria-labelledby', question.id);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ask-user-options';
|
||||
card.appendChild(list);
|
||||
|
||||
const send = (text) => {
|
||||
if (!text) return;
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.value = text;
|
||||
const sendButton = document.querySelector('.send-btn');
|
||||
if (sendButton) sendButton.click();
|
||||
};
|
||||
|
||||
opts.forEach((opt) => {
|
||||
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||
if (!label) return;
|
||||
const description = (opt && opt.description) ? String(opt.description) : '';
|
||||
const row = document.createElement(multi ? 'label' : 'button');
|
||||
row.className = 'ask-user-option';
|
||||
if (multi) {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = label;
|
||||
row.appendChild(checkbox);
|
||||
}
|
||||
const labelText = document.createElement('span');
|
||||
labelText.className = 'ask-user-option-label';
|
||||
labelText.innerHTML = emojiText(label);
|
||||
row.appendChild(labelText);
|
||||
if (description) {
|
||||
const descriptionText = document.createElement('span');
|
||||
descriptionText.className = 'ask-user-option-desc';
|
||||
descriptionText.innerHTML = emojiText(description);
|
||||
row.appendChild(descriptionText);
|
||||
}
|
||||
if (!multi) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => send(label));
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
const other = document.createElement('div');
|
||||
other.className = 'ask-user-other';
|
||||
const otherInput = document.createElement('input');
|
||||
otherInput.type = 'text';
|
||||
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||
const otherSend = document.createElement('button');
|
||||
otherSend.type = 'button';
|
||||
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||
otherSend.setAttribute('aria-label', 'Send answer');
|
||||
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||
const submit = () => {
|
||||
const freeText = otherInput.value.trim();
|
||||
if (multi) {
|
||||
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value);
|
||||
if (freeText) picked.push(freeText);
|
||||
if (picked.length) send(picked.join(', '));
|
||||
} else if (freeText) {
|
||||
send(freeText);
|
||||
}
|
||||
};
|
||||
otherSend.addEventListener('click', submit);
|
||||
otherInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
other.appendChild(otherInput);
|
||||
other.appendChild(otherSend);
|
||||
card.appendChild(other);
|
||||
|
||||
chatBox.appendChild(card);
|
||||
if (renderOptions.scroll !== false) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if (renderOptions.focus !== false) {
|
||||
try { card.focus(); } catch (_) {}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the chat history.
|
||||
*/
|
||||
@@ -1983,6 +2120,11 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) { console.error('Chat history element not found'); return; }
|
||||
|
||||
// Loading a later user message means any earlier ask_user card was
|
||||
// answered. This also removes the live card as soon as a manual reply is
|
||||
// appended, even when the user did not click one of its buttons.
|
||||
if (role === 'user') removeAskUserCards(box);
|
||||
|
||||
var esc = uiModule.esc;
|
||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||
|
||||
@@ -1990,6 +2132,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||
const roundTexts = metadata.round_texts || [];
|
||||
const toolEvents = metadata.tool_events;
|
||||
let pendingAskUser = null;
|
||||
let lastWrap = null;
|
||||
let firstMsgAi = null;
|
||||
let lastMsgAi = null;
|
||||
@@ -2066,6 +2209,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.appendChild(threadWrap);
|
||||
}
|
||||
for (const ev of roundTools) {
|
||||
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||
let outHtml = '';
|
||||
if (ev.output && ev.output.trim()) {
|
||||
@@ -2129,6 +2273,12 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
}
|
||||
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
||||
if (pendingAskUser) {
|
||||
// Session history is rendered oldest-to-newest. A later user message
|
||||
// removes this card; if there is none, the pending choice survives a
|
||||
// refresh. Avoid stealing focus while the history is loading.
|
||||
renderAskUserCard(pendingAskUser, { focus: false, scroll: false });
|
||||
}
|
||||
return lastWrap;
|
||||
}
|
||||
|
||||
@@ -2461,6 +2611,8 @@ const chatRenderer = {
|
||||
copyMessageText,
|
||||
safeToolScreenshotSrc,
|
||||
safeDisplayImageSrc,
|
||||
removeAskUserCards,
|
||||
renderAskUserCard,
|
||||
buildSourcesBox,
|
||||
buildFindingsBox,
|
||||
appendReportButton,
|
||||
|
||||
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js';
|
||||
import presetsModule from '../presets.js';
|
||||
import markdownModule from '../markdown.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
|
||||
}
|
||||
|
||||
let _exportMenuEl = null;
|
||||
let _closeExportMenu = () => {};
|
||||
function _toggleExportMenu(btn) {
|
||||
if (_exportMenuEl) { _closeExportMenu(); return; }
|
||||
const r = btn.getBoundingClientRect();
|
||||
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
|
||||
}
|
||||
document.body.appendChild(m);
|
||||
_exportMenuEl = m;
|
||||
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
|
||||
}
|
||||
function _closeExportMenu() {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
_closeExportMenu = bindMenuDismiss(m, () => {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
}, (ev) => !m.contains(ev.target));
|
||||
}
|
||||
|
||||
async function _exportCopyMarkdown(_btn) {
|
||||
|
||||
+7
-10
@@ -33,6 +33,8 @@ import {
|
||||
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
||||
} from './cookbookServe.js';
|
||||
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const STORAGE_KEY = 'cookbook-presets';
|
||||
const LAST_STATE_KEY = 'cookbook-last-state';
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
@@ -1514,7 +1516,7 @@ async function _fetchDependencies() {
|
||||
|
||||
// Wire the installed-package menu.
|
||||
function _showDepMenu(anchor) {
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||
const row = anchor.closest('.cookbook-dep-row');
|
||||
if (!row) return;
|
||||
const pipName = row.dataset.depPip;
|
||||
@@ -1535,7 +1537,7 @@ async function _fetchDependencies() {
|
||||
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
||||
it.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
close();
|
||||
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
||||
});
|
||||
dropdown.appendChild(it);
|
||||
@@ -1563,19 +1565,14 @@ async function _fetchDependencies() {
|
||||
dropdown.appendChild(source);
|
||||
}
|
||||
document.body.appendChild(dropdown);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
|
||||
!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
|
||||
}
|
||||
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (document.querySelector('.cookbook-dep-menu')) {
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||
return;
|
||||
}
|
||||
_showDepMenu(btn);
|
||||
|
||||
+21
-37
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
|
||||
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
|
||||
import signatureModule from './signature.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
let API_BASE = '';
|
||||
let isOpen = false;
|
||||
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
|
||||
let _docAiReplyChoiceMenu = null;
|
||||
function _closeDocAiReplyChoice() {
|
||||
if (_docAiReplyChoiceMenu) {
|
||||
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
|
||||
// Tear down through the menu's registered dismiss (drops its outside-click
|
||||
// listener + Escape-stack entry) rather than orphaning them with a raw
|
||||
// remove(); the onClose below nulls the ref.
|
||||
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
|
||||
_docAiReplyChoiceMenu = null;
|
||||
}
|
||||
}
|
||||
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
|
||||
const noteInput = menu.querySelector('[data-note-input]');
|
||||
setTimeout(() => noteInput?.focus(), 0);
|
||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
// Outside-click AND Escape both route through the central esc-stack via
|
||||
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
|
||||
const close = bindMenuDismiss(menu, () => {
|
||||
try { menu.remove(); } catch (_) {}
|
||||
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
|
||||
});
|
||||
menu.addEventListener('click', async (ev) => {
|
||||
const choice = ev.target.closest('[data-mode]');
|
||||
if (!choice) return;
|
||||
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
|
||||
ev.stopPropagation();
|
||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||
const noteHint = (noteInput?.value || '').trim();
|
||||
_closeDocAiReplyChoice();
|
||||
close();
|
||||
await _aiReply({ mode, noteHint });
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
const outsideClose = (ev) => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
document.removeEventListener('click', outsideClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
||||
// Esc to close.
|
||||
const escClose = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.stopPropagation();
|
||||
document.removeEventListener('keydown', escClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escClose, true);
|
||||
}
|
||||
|
||||
async function _aiReply(opts = {}) {
|
||||
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
function showExportMenu(e, anchorRect) {
|
||||
if (e) e.stopPropagation();
|
||||
// Remove existing menu if any
|
||||
// Remove existing menu if any (toggle off) — tear it down through its
|
||||
// registered dismiss so the outside-click listener + Escape-stack entry go.
|
||||
const existing = document.getElementById('doc-export-menu');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { dismissOrRemove(existing); return; }
|
||||
|
||||
// Position from provided rect, clicked element, or fallback to language select
|
||||
const rect = anchorRect
|
||||
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
|
||||
const item = document.createElement('button');
|
||||
item.className = 'doc-overflow-item';
|
||||
item.textContent = opt.label;
|
||||
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); });
|
||||
item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
|
||||
menu.appendChild(item);
|
||||
if (opt._divider) {
|
||||
const sep = document.createElement('div');
|
||||
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
|
||||
menu.style.top = 'auto';
|
||||
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
||||
}
|
||||
const close = (ev) => {
|
||||
if (ev && ev.type === 'keydown') {
|
||||
if (ev.key !== 'Escape') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.stopImmediatePropagation?.();
|
||||
} else if (ev && menu.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close);
|
||||
document.removeEventListener('keydown', close, true);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 100);
|
||||
document.addEventListener('keydown', close, true);
|
||||
// Outside-click AND Escape both route through the central esc-stack via
|
||||
// bindMenuDismiss; onClose owns the actual node removal.
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
}
|
||||
|
||||
function exportAsHtml() {
|
||||
|
||||
+6
-11
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
const _acct = () => window.__odysseusActiveEmailAccount
|
||||
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
}
|
||||
|
||||
function _showEmailMenu(em, anchor, itemEl) {
|
||||
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'dropdown email-dropdown show';
|
||||
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
||||
_showRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(menuItem);
|
||||
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
||||
|
||||
anchor.appendChild(dropdown);
|
||||
|
||||
const close = (e) => {
|
||||
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
|
||||
}
|
||||
|
||||
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
|
||||
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
||||
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
dismissOrRemove(parentDropdown);
|
||||
await _createReplyReminder(em, p.date);
|
||||
});
|
||||
parentDropdown.appendChild(item);
|
||||
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
||||
customItem.innerHTML = '<span>Pick date and time…</span>';
|
||||
customItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
dismissOrRemove(parentDropdown);
|
||||
const tmp = document.createElement('input');
|
||||
tmp.type = 'datetime-local';
|
||||
const def = new Date(tomorrow);
|
||||
|
||||
+24
-43
@@ -23,6 +23,7 @@ import {
|
||||
} from './emailLibrary/signatureFold.js';
|
||||
import { state } from './emailLibrary/state.js';
|
||||
import { collapseSidebarToRail } from './modalSnap.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
@@ -5499,16 +5500,12 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
||||
const existing = document.querySelector('.email-card-dropdown');
|
||||
if (existing && existing._anchor === anchor) {
|
||||
existing.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
dismissOrRemove(existing);
|
||||
return;
|
||||
}
|
||||
// Otherwise close any other open dropdown (and clear its anchor's active
|
||||
// state) before opening a fresh one.
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => {
|
||||
if (d._anchor) d._anchor.classList.remove('reader-more-active');
|
||||
d.remove();
|
||||
});
|
||||
// Otherwise close any other open dropdown (its own teardown clears its
|
||||
// anchor's active state) before opening a fresh one.
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
@@ -5721,8 +5718,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -5735,25 +5731,20 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
function _showCardMenu(em, anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
@@ -5918,8 +5909,7 @@ function _showCardMenu(em, anchor) {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -5932,26 +5922,21 @@ function _showCardMenu(em, anchor) {
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
||||
function _showBulkActionsMenu(anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
@@ -5968,7 +5953,7 @@ function _showBulkActionsMenu(anchor) {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||
@@ -5978,7 +5963,7 @@ function _showBulkActionsMenu(anchor) {
|
||||
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
|
||||
cancelIt.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
close();
|
||||
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
||||
// documents bulk dropdown.
|
||||
state._selectMode = false;
|
||||
@@ -5989,13 +5974,9 @@ function _showBulkActionsMenu(anchor) {
|
||||
dropdown.appendChild(cancelIt);
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
function _updateBulkBar() {
|
||||
|
||||
+5
-10
@@ -6,6 +6,7 @@ import uiModule from './ui.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -2514,7 +2515,7 @@ export function openGallery() {
|
||||
// shares the exact same dropdown style/behaviour.
|
||||
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
|
||||
function _showGalleryBulkMenu(anchor) {
|
||||
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
|
||||
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
|
||||
// matches every other menu in the app. Positioned fixed at the button.
|
||||
const dropdown = document.createElement('div');
|
||||
@@ -2548,17 +2549,11 @@ export function openGallery() {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
document.body.appendChild(dropdown);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
_bulkActionsBtn?.addEventListener('click', (e) => {
|
||||
@@ -2567,7 +2562,7 @@ export function openGallery() {
|
||||
// should close it. The outside-click handler explicitly skips clicks on
|
||||
// the anchor, so the button itself has to do its own dismiss.
|
||||
const existing = document.querySelector('.gallery-bulk-menu');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { dismissOrRemove(existing); return; }
|
||||
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
|
||||
_showGalleryBulkMenu(e.currentTarget);
|
||||
});
|
||||
|
||||
+8
-15
@@ -11,6 +11,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
|
||||
|
||||
function _pickCustomDate() {
|
||||
// Replace the dropdown menu with a small inline picker
|
||||
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
|
||||
document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'note-reminder-menu';
|
||||
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
|
||||
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
|
||||
if (typeof dInput.showPicker === 'function') {
|
||||
try { dInput.showPicker(); } catch {}
|
||||
}
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
|
||||
if (dInput.value) _setReminder(dInput.value);
|
||||
menu.remove();
|
||||
close();
|
||||
});
|
||||
setTimeout(() => {
|
||||
const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
|
||||
document.addEventListener('click', close);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
|
||||
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
|
||||
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
|
||||
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
|
||||
function _openNoteCornerMenu(btn) {
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
|
||||
const id = btn.dataset.noteId;
|
||||
const note = _notes.find(n => n.id === id);
|
||||
if (!note) return;
|
||||
@@ -4338,14 +4336,9 @@ function _openNoteCornerMenu(btn) {
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||
const close = (ev) => {
|
||||
if (ev && menu.contains(ev.target)) return;
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||
}
|
||||
|
||||
function _positionNoteMenu(menu, btn, width = 196) {
|
||||
|
||||
@@ -1938,6 +1938,7 @@ async function _onSessionListKeydown(e) {
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (item.querySelector('.session-rename-input')) return;
|
||||
e.preventDefault();
|
||||
const sid = item.dataset.sessionId;
|
||||
const s = sessions.find(x => x.id === sid);
|
||||
|
||||
+16
-9
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
|
||||
if (lbl) lbl.textContent = text;
|
||||
if (ico) ico.innerHTML = _apiIconFor(k);
|
||||
};
|
||||
const _close = () => { menu.style.display = 'none'; };
|
||||
// Menu is reused (hidden, not recreated). close() hides it and tears down
|
||||
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||
// re-registered fresh on each open (see _open).
|
||||
let _close = () => { menu.style.display = 'none'; };
|
||||
const _open = () => {
|
||||
menu.style.display = 'block';
|
||||
const tRect = trig.getBoundingClientRect();
|
||||
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
|
||||
const above = tRect.top;
|
||||
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
|
||||
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
|
||||
};
|
||||
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
||||
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
||||
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
|
||||
if (labelEl) labelEl.textContent = lbl;
|
||||
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
|
||||
};
|
||||
const _closeMenu = () => { menu.style.display = 'none'; };
|
||||
// Menu is reused (hidden, not recreated). _closeMenu hides it and tears
|
||||
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||
// re-registered fresh on each open (see _openMenu).
|
||||
let _closeMenu = () => { menu.style.display = 'none'; };
|
||||
const _openMenu = () => {
|
||||
menu.style.display = 'block';
|
||||
// Drop-up when there's not enough room below the trigger.
|
||||
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
|
||||
} else {
|
||||
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
||||
}
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
|
||||
};
|
||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
||||
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
||||
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
|
||||
addBtn.parentElement.style.position = 'relative';
|
||||
addBtn.parentElement.classList.add('uf-add-anchor');
|
||||
}
|
||||
// Menu is created per open and removed on close. _closeMenu routes through
|
||||
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
|
||||
// listener + Escape-stack entry are torn down alongside the node removal.
|
||||
let _menuEl = null;
|
||||
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
|
||||
let _closeMenu = () => {};
|
||||
addBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_menuEl) { _closeMenu(); return; }
|
||||
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
|
||||
showForm(k, 'new');
|
||||
});
|
||||
});
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -7,6 +7,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API = window.location.origin;
|
||||
let skills = [];
|
||||
@@ -391,14 +392,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
|
||||
// Kebab dropdown for a collapsed skill card — same actions + icons as the
|
||||
// expanded footer (Publish/Unpublish · Edit · Delete).
|
||||
function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
|
||||
document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'skill-kebab-menu';
|
||||
const mk = (paths, label, opts, onClick) => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
|
||||
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
|
||||
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
|
||||
item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
|
||||
menu.appendChild(item);
|
||||
};
|
||||
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
|
||||
@@ -410,7 +411,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
|
||||
selItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.remove();
|
||||
close();
|
||||
if (!_selectMode) _enterSelectMode();
|
||||
_selectedNames.add(name);
|
||||
renderSkillsList();
|
||||
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
const cancelItem = document.createElement('button');
|
||||
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
|
||||
cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
|
||||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
|
||||
menu.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
@@ -453,8 +454,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
|
||||
menu.style.overflowY = 'auto';
|
||||
}
|
||||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
|
||||
}
|
||||
|
||||
// Cards for the agent's built-in tool capabilities (from
|
||||
|
||||
+8
-10
@@ -8,6 +8,7 @@ import * as spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { ordinalSuffix } from './util/ordinal.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) {
|
||||
|
||||
function _showTaskDropdown(anchor, items) {
|
||||
// Remove any existing dropdown
|
||||
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'task-dropdown';
|
||||
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
||||
@@ -914,7 +915,7 @@ function _showTaskDropdown(anchor, items) {
|
||||
}
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
|
||||
dd.appendChild(btn);
|
||||
});
|
||||
document.body.appendChild(dd);
|
||||
@@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) {
|
||||
dd.style.top = top + 'px';
|
||||
dd.style.left = left + 'px';
|
||||
const openedAt = performance.now();
|
||||
const close = (e) => {
|
||||
const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
|
||||
// Ignore any clicks that occur within 250ms of the open (covers touch
|
||||
// "ghost click" duplicates that were firing right after pointerup and
|
||||
// removing the dropdown before the user could see it).
|
||||
if (performance.now() - openedAt < 250) return;
|
||||
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
|
||||
};
|
||||
// requestAnimationFrame so the listener is registered AFTER the current
|
||||
// pointer/click event cycle has finished bubbling.
|
||||
requestAnimationFrame(() => document.addEventListener('click', close));
|
||||
// removing the dropdown before the user could see it) — treat as inside.
|
||||
if (performance.now() - openedAt < 250) return false;
|
||||
return !dd.contains(ev.target);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Presets ----
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression coverage for durable ``ask_user`` choice cards.
|
||||
|
||||
The live event must arrive after ``tool_output`` so the settled tool trace
|
||||
cannot cover/push away the card. The same payload must be persisted inside
|
||||
``tool_events`` so chat history can reconstruct it after a reload.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import src.agent_loop as agent_loop
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _collect(gen):
|
||||
async def _run():
|
||||
return [chunk async for chunk in gen]
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _events(chunks):
|
||||
events = []
|
||||
for chunk in chunks:
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
events.append(json.loads(chunk[6:]))
|
||||
return events
|
||||
|
||||
|
||||
def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
|
||||
payload = {
|
||||
"question": "¿Qué proyecto prefieres?",
|
||||
"options": [
|
||||
{"label": "Análisis de reseñas"},
|
||||
{"label": "Clasificación temática"},
|
||||
],
|
||||
"multi": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False)
|
||||
|
||||
async def fake_stream(_candidates, messages, **kwargs):
|
||||
call = {"name": "ask_user", "arguments": json.dumps(payload, ensure_ascii=False)}
|
||||
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
async def fake_execute(block, *args, **kwargs):
|
||||
parsed = json.loads(block.content)
|
||||
return (
|
||||
"ask_user",
|
||||
{
|
||||
"ask_user": parsed,
|
||||
"output": "Awaiting their selection.",
|
||||
"exit_code": 0,
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False)
|
||||
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False)
|
||||
|
||||
chunks = _collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.openai.com/v1",
|
||||
"gpt-4o",
|
||||
[{"role": "user", "content": "Ayúdame a elegir un proyecto."}],
|
||||
relevant_tools={"ask_user"},
|
||||
_is_teacher_run=True,
|
||||
)
|
||||
)
|
||||
events = _events(chunks)
|
||||
|
||||
tool_output_index = next(i for i, event in enumerate(events) if event.get("type") == "tool_output")
|
||||
ask_user_index = next(i for i, event in enumerate(events) if event.get("type") == "ask_user")
|
||||
assert tool_output_index < ask_user_index
|
||||
|
||||
tool_output = events[tool_output_index]
|
||||
assert tool_output["ask_user"] == payload
|
||||
assert "¿Qué proyecto prefieres?" in tool_output["command"]
|
||||
assert "\\u00" not in tool_output["command"]
|
||||
|
||||
metrics = next(event["data"] for event in events if event.get("type") == "metrics")
|
||||
assert metrics["tool_events"][0]["ask_user"] == payload
|
||||
|
||||
|
||||
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
|
||||
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
||||
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "chatRenderer.renderAskUserCard(json.data || {})" in chat
|
||||
assert "export function renderAskUserCard" in renderer
|
||||
assert "renderAskUserCard(pendingAskUser" in renderer
|
||||
assert "if (role === 'user') removeAskUserCards(box)" in renderer
|
||||
@@ -85,6 +85,19 @@ def test_serializer_round_trips_structured_args():
|
||||
assert json.loads(block.content) == args
|
||||
|
||||
|
||||
def test_serializer_keeps_unicode_readable_for_tool_trace():
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
|
||||
args = {
|
||||
"question": "¿Qué proyecto prefieres?",
|
||||
"options": [{"label": "Reseñas"}, {"label": "Clasificación"}],
|
||||
}
|
||||
block = function_call_to_tool_block("ask_user", json.dumps(args, ensure_ascii=False))
|
||||
assert "¿Qué proyecto prefieres?" in block.content
|
||||
assert "Reseñas" in block.content
|
||||
assert "\\u00" not in block.content
|
||||
|
||||
|
||||
def test_registered_everywhere():
|
||||
# TOOL_TAGS gate (serializer rejects unknown tools)
|
||||
assert "ask_user" in TOOL_TAGS
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Regression tests for auth-disabled document access (PR #4623).
|
||||
|
||||
Validates that the _auth_disabled() bypass in _verify_doc_owner and
|
||||
list_documents restores single-user / no-auth mode WITHOUT weakening the
|
||||
authenticated path. Three pinned directions:
|
||||
|
||||
1. AUTH_DISABLED + None user -> list_documents + doc read SUCCEEDS
|
||||
(the bug being fixed).
|
||||
2. AUTH_ENABLED + None user -> still 403.
|
||||
3. AUTH_ENABLED + wrong owner -> _verify_doc_owner still raises 404/403.
|
||||
|
||||
Route handlers are called directly (same pattern as
|
||||
test_document_session_owner_scope.py) so coverage lands on the real
|
||||
closures without spinning up middleware.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb
|
||||
import routes.document_routes as droutes
|
||||
from core.database import Document
|
||||
from core.database import Session as DbSession
|
||||
from routes.document_helpers import _verify_doc_owner, _owner_session_filter
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(
|
||||
f"sqlite:///{_TMPDB.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(_ENGINE)
|
||||
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
|
||||
def _req(user=None):
|
||||
"""Build a minimal fake Request whose state.current_user returns *user*."""
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||
|
||||
|
||||
def _endpoint(method, path):
|
||||
"""Resolve a route endpoint from the document router."""
|
||||
router = droutes.setup_document_routes(MagicMock(), None)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise RuntimeError(f"{method} {path} not found")
|
||||
|
||||
|
||||
def _bind_test_db():
|
||||
previous = droutes.SessionLocal
|
||||
droutes.SessionLocal = _TS
|
||||
return previous
|
||||
|
||||
|
||||
def _seed(owner="alice"):
|
||||
"""Create one session + one owned document. Returns (session_id, doc_id)."""
|
||||
session_id = f"{owner}-" + uuid.uuid4().hex[:8]
|
||||
doc_id = str(uuid.uuid4())
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(DbSession(
|
||||
id=session_id, owner=owner, name=owner,
|
||||
model="m", endpoint_url="http://x",
|
||||
))
|
||||
db.add(Document(
|
||||
id=doc_id,
|
||||
session_id=session_id,
|
||||
title=f"{owner} doc",
|
||||
language="markdown",
|
||||
current_content=f"{owner} body",
|
||||
version_count=1,
|
||||
is_active=True,
|
||||
owner=owner,
|
||||
))
|
||||
db.commit()
|
||||
return session_id, doc_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------ 1. auth DISABLED +
|
||||
# None user -> succeeds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_allows_none_user_when_auth_disabled(monkeypatch):
|
||||
"""AUTH_ENABLED=false + user=None must NOT raise 403 on list_documents."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||
session_id, doc_id = _seed()
|
||||
|
||||
# Must succeed — this is the bug fix.
|
||||
rows = await list_docs(_req(None), session_id)
|
||||
ids = [row["id"] for row in rows]
|
||||
assert doc_id in ids, "own doc must be visible in auth-disabled mode"
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_allows_none_user_when_auth_disabled(monkeypatch):
|
||||
"""AUTH_ENABLED=false + user=None must NOT raise 403 on get_document."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||
_session_id, doc_id = _seed()
|
||||
|
||||
# Must succeed — _verify_doc_owner bypasses when auth is disabled.
|
||||
result = await get_doc(_req(None), doc_id)
|
||||
assert result["id"] == doc_id
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
def test_verify_doc_owner_allows_none_user_when_auth_disabled(monkeypatch):
|
||||
"""_verify_doc_owner with user=None + AUTH_ENABLED=false must pass."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
_session_id, doc_id = _seed()
|
||||
db = _TS()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
# Must NOT raise — the bypass allows single-user access.
|
||||
_verify_doc_owner(db, doc, None)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_owner_session_filter_noops_for_none_user_when_auth_disabled(monkeypatch):
|
||||
"""_owner_session_filter with user=None + AUTH_ENABLED=false returns query unchanged."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
_session_id, doc_id = _seed()
|
||||
db = _TS()
|
||||
try:
|
||||
q = db.query(Document).filter(Document.id == doc_id)
|
||||
result = _owner_session_filter(q, None)
|
||||
# Filter was a no-op; document is still reachable.
|
||||
assert result.first().id == doc_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------ 2. auth ENABLED +
|
||||
# None user -> 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||
"""AUTH_ENABLED=true (default) + user=None must raise 403."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||
session_id, _doc_id = _seed()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await list_docs(_req(None), session_id)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||
"""AUTH_ENABLED=true (default) + user=None must raise 403 via _verify_doc_owner."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||
_session_id, doc_id = _seed()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_doc(_req(None), doc_id)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
def test_verify_doc_owner_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||
"""_verify_doc_owner with user=None + AUTH_ENABLED=true must raise 403."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
_session_id, doc_id = _seed()
|
||||
db = _TS()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_verify_doc_owner(db, doc, None)
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------ 3. auth ENABLED + wrong owner ->
|
||||
# _verify_doc_owner raises 404
|
||||
|
||||
|
||||
def test_verify_doc_owner_rejects_wrong_owner_when_auth_enabled(monkeypatch):
|
||||
"""_verify_doc_owner with a mismatched owner must raise 404 (not 403).
|
||||
|
||||
This confirms the authenticated path is untouched by the no-auth bypass."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
session_id, doc_id = _seed(owner="alice")
|
||||
db = _TS()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_verify_doc_owner(db, doc, "bob") # bob != alice
|
||||
assert exc.value.status_code == 404
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_rejects_wrong_owner(monkeypatch):
|
||||
"""GET /api/document/{doc_id} with wrong authenticated user -> 404."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||
_session_id, doc_id = _seed(owner="alice")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_doc(_req("bob"), doc_id)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_hides_wrong_owner_docs(monkeypatch):
|
||||
"""list_documents for alice must not show bob's documents."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||
|
||||
# Seed alice's session with a doc
|
||||
alice_session, alice_doc = _seed(owner="alice")
|
||||
# Create bob's session+doc in the SAME session so ownership filter kicks in
|
||||
bob_session = "bob-" + uuid.uuid4().hex[:8]
|
||||
bob_doc = str(uuid.uuid4())
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(DbSession(id=bob_session, owner="bob", name="bob", model="m", endpoint_url="http://x"))
|
||||
db.add(Document(
|
||||
id=bob_doc, session_id=alice_session, # same session!
|
||||
title="bob doc", language="markdown", current_content="bob body",
|
||||
version_count=1, is_active=True, owner="bob",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
rows = await list_docs(_req("alice"), alice_session)
|
||||
ids = [row["id"] for row in rows]
|
||||
assert alice_doc in ids
|
||||
assert bob_doc not in ids, "wrong-owner docs must be hidden"
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
@@ -0,0 +1,107 @@
|
||||
r"""DOM/CSS-injection regression for calendar background-image URL escaping.
|
||||
|
||||
CodeQL `js/incomplete-sanitization` (#463 calendar.js:416, #464 calendar.js:1263)
|
||||
flagged event-background CSS that escaped `'` -> `\'` without first escaping
|
||||
backslashes. A `bg:`-color value (settable per event, and CalDAV-syncable, so
|
||||
untrusted) ending in or containing a backslash can then consume the closing
|
||||
quote of `url('...')` and break out of the CSS string.
|
||||
|
||||
The fix is a single canonical escaper, `_cssUrlEscape`, in calendar/utils.js,
|
||||
used by both inline sinks and by `_calBgCss` (which had the same incomplete
|
||||
escaping). These tests pin the escaper: backslashes are doubled FIRST, then
|
||||
quotes, so no input can terminate the `url('...')` string early.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_UTILS = (_REPO / "static" / "js" / "calendar" / "utils.js").as_posix()
|
||||
_CALENDAR_JS = _REPO / "static" / "js" / "calendar.js"
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
|
||||
|
||||
def _run(js: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def test_cssurlescape_doubles_backslashes_before_quotes():
|
||||
js = textwrap.dedent(
|
||||
f"""
|
||||
const {{ _cssUrlEscape }} = await import('{_UTILS}');
|
||||
console.log(JSON.stringify({{
|
||||
backslash: _cssUrlEscape('a\\\\b'),
|
||||
trailing: _cssUrlEscape('img\\\\'),
|
||||
quote: _cssUrlEscape("a'b"),
|
||||
dquote: _cssUrlEscape('a"b'),
|
||||
}}));
|
||||
"""
|
||||
)
|
||||
out = json.loads(_run(js))
|
||||
# one backslash -> two; the escape for "'" is not itself re-escaped
|
||||
assert out["backslash"] == r"a\\b"
|
||||
assert out["trailing"] == "img\\\\" # 'img\' -> 'img\\'
|
||||
assert out["quote"] == r"a\'b"
|
||||
assert out["dquote"] == "a%22b"
|
||||
|
||||
|
||||
def test_backslash_breakout_payload_cannot_close_the_url_string():
|
||||
# Without the backslash-first escape, "x\" would render url('x\') and the
|
||||
# trailing backslash escapes the closing quote -> breakout. After the fix the
|
||||
# backslash is doubled, so the quote we add still terminates the string.
|
||||
js = textwrap.dedent(
|
||||
f"""
|
||||
const {{ _cssUrlEscape, _calBgCss }} = await import('{_UTILS}');
|
||||
const payload = 'x\\\\'; // a string ending in one backslash
|
||||
console.log(JSON.stringify({{
|
||||
esc: _cssUrlEscape(payload),
|
||||
css: _calBgCss('bg:' + payload, 'var(--accent)'),
|
||||
}}));
|
||||
"""
|
||||
)
|
||||
out = json.loads(_run(js))
|
||||
assert out["esc"] == "x\\\\" # doubled backslash
|
||||
# The rendered declaration keeps the backslash doubled inside url('...').
|
||||
assert "url('x\\\\')" in out["css"]
|
||||
|
||||
|
||||
def test_calbgcss_escapes_quote_breakout():
|
||||
js = textwrap.dedent(
|
||||
f"""
|
||||
const {{ _calBgCss }} = await import('{_UTILS}');
|
||||
console.log(JSON.stringify(_calBgCss("bg:a'); X{{}}//", 'var(--accent)')));
|
||||
"""
|
||||
)
|
||||
css = json.loads(_run(js))
|
||||
# the injected single quote is escaped, so the url() string is not closed early
|
||||
assert r"\'" in css
|
||||
assert "url('a\\'); X{}//')" in css
|
||||
|
||||
|
||||
def test_every_calendar_url_interpolation_is_escaped():
|
||||
# Whole-file invariant: every CSS `url('${...}')` built in calendar.js must
|
||||
# route its (CalDAV-syncable, untrusted) value through `_cssUrlEscape`. This
|
||||
# is the guard that catches a *newly added* bg-image sink the centralization
|
||||
# forgot - the failure mode that left calendar.js:2856 (edit-form color
|
||||
# swatch) and :2953 (custom-dot preview) raw before this change.
|
||||
src = _CALENDAR_JS.read_text(encoding="utf-8")
|
||||
interps = re.findall(r"url\('\$\{([^}]*)\}'\)", src)
|
||||
assert interps, "expected at least one url('${...}') interpolation in calendar.js"
|
||||
unescaped = [expr for expr in interps if "_cssUrlEscape(" not in expr]
|
||||
assert not unescaped, (
|
||||
"bg-image url() interpolation(s) not routed through _cssUrlEscape: "
|
||||
+ ", ".join(repr(e) for e in unescaped)
|
||||
)
|
||||
@@ -72,3 +72,50 @@ def test_gguf_alternate_still_recommended_on_windows():
|
||||
still appear on Windows even though the AWQ variant is hidden."""
|
||||
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
|
||||
assert "Qwen/Qwen2.5-3B-Instruct" in names
|
||||
|
||||
|
||||
def test_remote_windows_probe_uses_encoded_command(monkeypatch):
|
||||
"""Remote Windows hwfit must not use nested -Command quoting over SSH."""
|
||||
from services.hwfit import hardware
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(hardware, "_remote_host", "user@winpc")
|
||||
monkeypatch.setattr(hardware, "_remote_port", None)
|
||||
|
||||
def fake_run(cmd):
|
||||
calls.append(cmd)
|
||||
if isinstance(cmd, str) and "EncodedCommand" in cmd:
|
||||
return (
|
||||
'{"ram_gb":64,"avail_gb":32,"cpu_name":"Test CPU",'
|
||||
'"cpu_cores":8,"arch":64}'
|
||||
)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(hardware, "_run", fake_run)
|
||||
result = hardware._detect_windows()
|
||||
assert result is not None
|
||||
assert result["total_ram_gb"] == 64
|
||||
assert len(calls) == 1
|
||||
assert "EncodedCommand" in calls[0]
|
||||
assert '-Command "' not in calls[0]
|
||||
|
||||
|
||||
def test_probe_remote_platform_detects_windows(monkeypatch):
|
||||
from services.hwfit import hardware
|
||||
|
||||
monkeypatch.setattr(hardware, "_run", lambda cmd: "Windows_NT\n")
|
||||
assert hardware._probe_remote_platform() == "windows"
|
||||
|
||||
|
||||
def test_probe_remote_platform_detects_darwin(monkeypatch):
|
||||
from services.hwfit import hardware
|
||||
|
||||
def fake_run(cmd):
|
||||
if cmd == "echo %OS%":
|
||||
return "%OS%"
|
||||
if cmd == ["uname", "-s"]:
|
||||
return "Darwin"
|
||||
raise AssertionError(f"unexpected probe cmd: {cmd!r}")
|
||||
|
||||
monkeypatch.setattr(hardware, "_run", fake_run)
|
||||
assert hardware._probe_remote_platform() == "linux"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from core.log_safety import redact_url
|
||||
|
||||
|
||||
def test_strips_userinfo():
|
||||
assert redact_url("https://user:pass@host.example/v1/models") == "https://host.example/v1/models"
|
||||
|
||||
|
||||
def test_strips_query_and_fragment():
|
||||
assert redact_url("https://host.example/v1?api_key=secret#frag") == "https://host.example/v1"
|
||||
|
||||
|
||||
def test_keeps_port_and_path():
|
||||
assert redact_url("http://host.example:8080/api/tags") == "http://host.example:8080/api/tags"
|
||||
|
||||
|
||||
def test_ipv6_host_keeps_brackets():
|
||||
assert redact_url("https://user:pass@[2001:db8::1]:8443/v1") == "https://[2001:db8::1]:8443/v1"
|
||||
assert redact_url("https://[2001:db8::1]/v1") == "https://[2001:db8::1]/v1"
|
||||
|
||||
|
||||
def test_no_credentials_passthrough():
|
||||
assert redact_url("https://host.example/v1/models") == "https://host.example/v1/models"
|
||||
|
||||
|
||||
def test_empty_and_none():
|
||||
assert redact_url("") == ""
|
||||
assert redact_url(None) == ""
|
||||
|
||||
|
||||
def test_garbage_does_not_raise():
|
||||
# urlparse is lenient; just assert no credential-looking userinfo survives.
|
||||
assert "@" not in redact_url("::::not a url::::")
|
||||
Reference in New Issue
Block a user