mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-07 21:52:20 +01:00
Compare commits
10
Commits
| 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`.
|
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||||
Use that for the first login, then change it in **Settings**.
|
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.
|
pull request guidelines.
|
||||||
|
|
||||||
### Docker (recommended)
|
### 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
|
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||||
3.11+ version) for the venv step.
|
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,
|
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||||
model downloads and the agent shell tool, also install
|
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 SessionLocal, get_session_mode, set_session_mode
|
||||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||||
from core.database import Document as DBDocument, ModelEndpoint
|
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.research_routes import _resolve_research_endpoint
|
||||||
from routes.model_routes import _visible_models
|
from routes.model_routes import _visible_models
|
||||||
from routes.chat_helpers import (
|
from routes.chat_helpers import (
|
||||||
@@ -930,7 +931,7 @@ def setup_chat_routes(
|
|||||||
if effective_do_research:
|
if effective_do_research:
|
||||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||||
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
_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.
|
# 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
|
# 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 datetime import datetime
|
||||||
from urllib.parse import urljoin, urlparse, urlunparse
|
from urllib.parse import urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
|
from core.log_safety import redact_url
|
||||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
@@ -689,15 +690,24 @@ def _delete_contact(uid: str) -> bool:
|
|||||||
url = _resolve_resource_url(uid)
|
url = _resolve_resource_url(uid)
|
||||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||||
r = httpx.delete(url, auth=auth, timeout=10)
|
r = httpx.delete(url, auth=auth, timeout=10)
|
||||||
if r.status_code in (200, 204):
|
if r.status_code in (200, 204, 404):
|
||||||
_contact_cache["fetched_at"] = None
|
# Invalidate cache so the next fetch sees the server truth.
|
||||||
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")
|
|
||||||
_contact_cache["fetched_at"] = None
|
_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
|
return True
|
||||||
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import Document, DocumentVersion
|
from core.database import Document, DocumentVersion
|
||||||
from core.database import Session as DbSession
|
from core.database import Session as DbSession
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
from src.upload_handler import UploadHandler
|
from src.upload_handler import UploadHandler
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
the session join for any not-yet-backfilled legacy row.
|
||||||
"""
|
"""
|
||||||
if user is None:
|
if user is None:
|
||||||
|
if _auth_disabled():
|
||||||
|
return # Single-user / no-auth mode: allow access
|
||||||
raise HTTPException(403, "Authentication required")
|
raise HTTPException(403, "Authentication required")
|
||||||
if doc.owner is not None:
|
if doc.owner is not None:
|
||||||
if doc.owner != user:
|
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;
|
by the time this filter is live there are no NULL-owner rows to leak;
|
||||||
we therefore match the owner strictly for authenticated callers."""
|
we therefore match the owner strictly for authenticated callers."""
|
||||||
if not user:
|
if not user:
|
||||||
from src.auth_helpers import _auth_disabled
|
|
||||||
if user == "" or _auth_disabled():
|
if user == "" or _auth_disabled():
|
||||||
return q
|
return q
|
||||||
return q.filter(False)
|
return q.filter(False)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
|
|||||||
from sqlalchemy import case, func, or_
|
from sqlalchemy import case, func, or_
|
||||||
from core.database import SessionLocal, Document, DocumentVersion
|
from core.database import SessionLocal, Document, DocumentVersion
|
||||||
from core.database import Session as DbSession
|
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
|
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -388,7 +388,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if not user:
|
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
|
# v2 review HIGH-9: raise 403 explicitly when the caller
|
||||||
# can't see this session, instead of returning [] which the
|
# can't see this session, instead of returning [] which the
|
||||||
# UI treats identically to "no docs" and silently masks
|
# 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 pydantic import BaseModel
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
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 core.middleware import require_admin
|
||||||
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
||||||
from src.tls_overrides import llm_verify
|
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 {}
|
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:
|
def _is_discovery_only_provider(provider: str) -> bool:
|
||||||
return provider == "chatgpt-subscription"
|
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)
|
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if api_key:
|
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 []
|
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
|
# Older Ollama builds and some proxies expose native /api/tags even when
|
||||||
# the OpenAI-compatible /v1/models path is unavailable.
|
# 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
|
# 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).
|
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||||||
logger.info(
|
logger.info(
|
||||||
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
|
"dispatch_reminder[email] note_id=%s owner=%r "
|
||||||
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
|
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
||||||
f"from={from_addr!r} recipient={recipient!r} "
|
note_id, owner,
|
||||||
f"account_name={cfg.get('account_name')!r}"
|
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
||||||
|
bool(from_addr), bool(recipient),
|
||||||
)
|
)
|
||||||
missing = []
|
missing = []
|
||||||
if not cfg.get("smtp_host"):
|
if not cfg.get("smtp_host"):
|
||||||
|
|||||||
@@ -538,6 +538,32 @@ def _powershell_exe():
|
|||||||
path so we don't depend on a particular PATH ordering."""
|
path so we don't depend on a particular PATH ordering."""
|
||||||
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
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():
|
def _detect_windows():
|
||||||
"""Detect Windows hardware via PowerShell/WMI.
|
"""Detect Windows hardware via PowerShell/WMI.
|
||||||
@@ -600,9 +626,8 @@ def _detect_windows():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
if _remote_host:
|
if _remote_host:
|
||||||
# Remote: ship a single command string over SSH. The remote shell parses
|
# Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
|
||||||
# the quoting; PowerShell on the far side runs the -Command payload.
|
out = _powershell_encoded_for_ssh(ps_cmd.strip())
|
||||||
out = _run(f'powershell -Command "{ps_cmd}"')
|
|
||||||
else:
|
else:
|
||||||
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
||||||
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
|
# 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
|
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)
|
cache_key = _cache_key(host, ssh_port, platform)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if not fresh and cache_key in _cache_by_host:
|
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
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
return result
|
return result
|
||||||
# If Windows detection failed, return error
|
# SSH may work while the PowerShell hardware probe still fails.
|
||||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_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'
|
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
# ask_user: the agent posed a multiple-choice question. Emit it so the
|
# ask_user: remember the payload now, but emit the interactive event
|
||||||
# frontend renders clickable options, then end the turn (below) and
|
# only *after* tool_output below. Emitting it before tool_output let
|
||||||
# wait — the user's pick becomes the next message.
|
# 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:
|
if "ask_user" in result:
|
||||||
# The question lives in the tool args. ChatMessage.to_dict()
|
# The question lives in the tool args. ChatMessage.to_dict()
|
||||||
# replays only role+content to the model next turn — tool_event
|
# 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
|
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
||||||
full_response += _auq_delta
|
full_response += _auq_delta
|
||||||
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
||||||
yield (
|
_pending_ask_user_event = _auq
|
||||||
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
|
|
||||||
)
|
|
||||||
_awaiting_user = True
|
_awaiting_user = True
|
||||||
|
|
||||||
# update_plan: agent wrote back to the plan (ticked a step / revised).
|
# 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)
|
# 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")}
|
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:
|
if "ui_event" in result:
|
||||||
tool_output_data["ui_event"] = result["ui_event"]
|
tool_output_data["ui_event"] = result["ui_event"]
|
||||||
for k in (
|
for k in (
|
||||||
@@ -3319,6 +3324,14 @@ async def stream_agent_loop(
|
|||||||
tool_output_data["diff"] = result["diff"]
|
tool_output_data["diff"] = result["diff"]
|
||||||
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
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.
|
# 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
|
# 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"
|
# 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.
|
# this the diff shows live but vanishes from saved history.
|
||||||
if result.get("diff"):
|
if result.get("diff"):
|
||||||
tool_event["diff"] = result["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)
|
tool_events.append(tool_event)
|
||||||
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
||||||
_effectful_used = True
|
_effectful_used = True
|
||||||
|
|||||||
@@ -1667,7 +1667,7 @@ class TaskScheduler:
|
|||||||
msg["X-Odysseus-Ref"] = str(task.id)
|
msg["X-Odysseus-Ref"] = str(task.id)
|
||||||
msg.set_content(result or "")
|
msg.set_content(result or "")
|
||||||
_send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30)
|
_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:
|
except Exception as e:
|
||||||
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
|
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
|
||||||
raise
|
raise
|
||||||
@@ -2029,7 +2029,7 @@ class TaskScheduler:
|
|||||||
# silent SMTP failure is easier to spot in the logs.
|
# silent SMTP failure is easier to spot in the logs.
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Task {task.id} MCP delivery failed: {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).",
|
"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.",
|
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||||
"search_chats": "Search past session transcripts across chats.",
|
"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.",
|
"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.",
|
"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.",
|
"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."},
|
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
|
||||||
"options": {
|
"options": {
|
||||||
"type": "array",
|
"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": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"required": ["label"]
|
"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"]
|
"required": ["question", "options"]
|
||||||
}
|
}
|
||||||
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
|||||||
content = json.dumps(args)
|
content = json.dumps(args)
|
||||||
elif tool_type == "ask_teacher":
|
elif tool_type == "ask_teacher":
|
||||||
content = args.get("model", "auto") + "\n" + args.get("problem", "")
|
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:
|
else:
|
||||||
content = json.dumps(args)
|
content = json.dumps(args)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
||||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||||
_trashIcon, _moreIcon, _bellIcon,
|
_trashIcon, _moreIcon, _bellIcon,
|
||||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
_isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
|
||||||
_calReadableTextColor,
|
_calReadableTextColor,
|
||||||
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
||||||
} from './calendar/utils.js';
|
} from './calendar/utils.js';
|
||||||
@@ -413,8 +413,8 @@ function _calEventFg(ev) {
|
|||||||
// Returns '' for normal solid-color events.
|
// Returns '' for normal solid-color events.
|
||||||
function _calItemBgStyle(ev) {
|
function _calItemBgStyle(ev) {
|
||||||
if (!_isCalBgImage(ev.color)) return '';
|
if (!_isCalBgImage(ev.color)) return '';
|
||||||
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
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('${url}'); background-size: cover; background-position: center;`;
|
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() {
|
function _todayCount() {
|
||||||
@@ -1260,8 +1260,8 @@ async function _renderWeek() {
|
|||||||
// events keep the original tinted treatment.
|
// events keep the original tinted treatment.
|
||||||
let bgDecl;
|
let bgDecl;
|
||||||
if (_isCalBgImage(ev.color)) {
|
if (_isCalBgImage(ev.color)) {
|
||||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
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('${_url}'); background-size: cover; background-position: center;`;
|
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 {
|
} else {
|
||||||
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
||||||
}
|
}
|
||||||
@@ -2853,7 +2853,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
let bg;
|
let bg;
|
||||||
if (isCustom) {
|
if (isCustom) {
|
||||||
const url = _calBgImageUrl(cur);
|
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 {
|
} else {
|
||||||
bg = c.hex || 'var(--border)';
|
bg = c.hex || 'var(--border)';
|
||||||
}
|
}
|
||||||
@@ -2928,7 +2928,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
// stays readable. Chrome accent falls back to the theme accent.
|
// stays readable. Chrome accent falls back to the theme accent.
|
||||||
const url = _calBgImageUrl(hex);
|
const url = _calBgImageUrl(hex);
|
||||||
_formCard.style.setProperty('--ev-color', 'var(--accent)');
|
_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.backgroundSize = 'cover';
|
||||||
_formCard.style.backgroundPosition = 'center';
|
_formCard.style.backgroundPosition = 'center';
|
||||||
_formCard.classList.add('cal-form-bg-image');
|
_formCard.classList.add('cal-form-bg-image');
|
||||||
@@ -2950,7 +2950,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
if (!url) return;
|
if (!url) return;
|
||||||
const sentinel = 'bg:' + url;
|
const sentinel = 'bg:' + url;
|
||||||
dot.dataset.color = sentinel;
|
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'));
|
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
|
||||||
dot.classList.add('active');
|
dot.classList.add('active');
|
||||||
_applyFormTint(sentinel);
|
_applyFormTint(sentinel);
|
||||||
|
|||||||
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
|
|||||||
return _isCalBgImage(c) ? c.slice(3) : '';
|
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
|
// 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
|
// 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).
|
// too small to render usefully (small grid dots, multi-day bars).
|
||||||
export function _calBgCss(c, fallback) {
|
export function _calBgCss(c, fallback) {
|
||||||
if (_isCalBgImage(c)) {
|
if (_isCalBgImage(c)) {
|
||||||
const u = _calBgImageUrl(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)';
|
return c || fallback || 'var(--accent)';
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-142
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
|
|||||||
import chatStream from './chatStream.js';
|
import chatStream from './chatStream.js';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
import { svgifyEmoji } from './markdown.js';
|
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import presetsModule from './presets.js';
|
import presetsModule from './presets.js';
|
||||||
import fileHandlerModule from './fileHandler.js';
|
import fileHandlerModule from './fileHandler.js';
|
||||||
@@ -2321,148 +2320,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
} else if (json.type === 'ask_user') {
|
} else if (json.type === 'ask_user') {
|
||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
// The agent posed a multiple-choice question; the turn has ended.
|
// The agent posed a multiple-choice question; the turn has ended.
|
||||||
// Render clickable options at the bottom of the history. The
|
// Use the shared history renderer so the live and restored
|
||||||
// user's pick is sent as the next message and the agent resumes.
|
// versions have identical behavior.
|
||||||
_cancelThinkingTimer();
|
_cancelThinkingTimer();
|
||||||
_removeThinkingSpinner();
|
_removeThinkingSpinner();
|
||||||
const _aq = json.data || {};
|
chatRenderer.renderAskUserCard(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 (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (json.type === 'plan_update') {
|
} else if (json.type === 'plan_update') {
|
||||||
if (_isBg) continue;
|
if (_isBg) continue;
|
||||||
@@ -5019,7 +4881,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
if (!header) return;
|
if (!header) return;
|
||||||
const node = header.closest('.agent-thread-node');
|
const node = header.closest('.agent-thread-node');
|
||||||
if (!node) return;
|
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;
|
window.__odysseus_thread_click_bound = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import markdownModule from './markdown.js';
|
import markdownModule from './markdown.js';
|
||||||
|
import { svgifyEmoji } from './markdown.js';
|
||||||
import { addAITTSButton } from './tts-ai.js';
|
import { addAITTSButton } from './tts-ai.js';
|
||||||
import { providerLogo, providerLabel } from './providers.js';
|
import { providerLogo, providerLabel } from './providers.js';
|
||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
@@ -1974,6 +1975,142 @@ export function displayMetrics(messageElement, metrics) {
|
|||||||
if (uiModule) uiModule.scrollHistory();
|
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.
|
* Add a message to the chat history.
|
||||||
*/
|
*/
|
||||||
@@ -1983,6 +2120,11 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const box = document.getElementById('chat-history');
|
const box = document.getElementById('chat-history');
|
||||||
if (!box) { console.error('Chat history element not found'); return; }
|
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;
|
var esc = uiModule.esc;
|
||||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
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) {
|
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||||
const roundTexts = metadata.round_texts || [];
|
const roundTexts = metadata.round_texts || [];
|
||||||
const toolEvents = metadata.tool_events;
|
const toolEvents = metadata.tool_events;
|
||||||
|
let pendingAskUser = null;
|
||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
let lastMsgAi = null;
|
let lastMsgAi = null;
|
||||||
@@ -2066,6 +2209,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
|
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||||
let outHtml = '';
|
let outHtml = '';
|
||||||
if (ev.output && ev.output.trim()) {
|
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));
|
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||||
}
|
}
|
||||||
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
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;
|
return lastWrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2461,6 +2611,8 @@ const chatRenderer = {
|
|||||||
copyMessageText,
|
copyMessageText,
|
||||||
safeToolScreenshotSrc,
|
safeToolScreenshotSrc,
|
||||||
safeDisplayImageSrc,
|
safeDisplayImageSrc,
|
||||||
|
removeAskUserCards,
|
||||||
|
renderAskUserCard,
|
||||||
buildSourcesBox,
|
buildSourcesBox,
|
||||||
buildFindingsBox,
|
buildFindingsBox,
|
||||||
appendReportButton,
|
appendReportButton,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
|
|||||||
import themeModule from '../theme.js';
|
import themeModule from '../theme.js';
|
||||||
import presetsModule from '../presets.js';
|
import presetsModule from '../presets.js';
|
||||||
import markdownModule from '../markdown.js';
|
import markdownModule from '../markdown.js';
|
||||||
|
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||||
|
|
||||||
var escapeHtml = uiModule.esc;
|
var escapeHtml = uiModule.esc;
|
||||||
|
|
||||||
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _exportMenuEl = null;
|
let _exportMenuEl = null;
|
||||||
|
let _closeExportMenu = () => {};
|
||||||
function _toggleExportMenu(btn) {
|
function _toggleExportMenu(btn) {
|
||||||
if (_exportMenuEl) { _closeExportMenu(); return; }
|
if (_exportMenuEl) { _closeExportMenu(); return; }
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
|
|||||||
}
|
}
|
||||||
document.body.appendChild(m);
|
document.body.appendChild(m);
|
||||||
_exportMenuEl = m;
|
_exportMenuEl = m;
|
||||||
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
|
_closeExportMenu = bindMenuDismiss(m, () => {
|
||||||
}
|
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||||
function _closeExportMenu() {
|
}, (ev) => !m.contains(ev.target));
|
||||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _exportCopyMarkdown(_btn) {
|
async function _exportCopyMarkdown(_btn) {
|
||||||
|
|||||||
+7
-10
@@ -33,6 +33,8 @@ import {
|
|||||||
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
||||||
} from './cookbookServe.js';
|
} from './cookbookServe.js';
|
||||||
|
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const STORAGE_KEY = 'cookbook-presets';
|
const STORAGE_KEY = 'cookbook-presets';
|
||||||
const LAST_STATE_KEY = 'cookbook-last-state';
|
const LAST_STATE_KEY = 'cookbook-last-state';
|
||||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||||
@@ -1514,7 +1516,7 @@ async function _fetchDependencies() {
|
|||||||
|
|
||||||
// Wire the installed-package menu.
|
// Wire the installed-package menu.
|
||||||
function _showDepMenu(anchor) {
|
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');
|
const row = anchor.closest('.cookbook-dep-row');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const pipName = row.dataset.depPip;
|
const pipName = row.dataset.depPip;
|
||||||
@@ -1535,7 +1537,7 @@ async function _fetchDependencies() {
|
|||||||
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
||||||
it.addEventListener('click', async (e) => {
|
it.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
||||||
});
|
});
|
||||||
dropdown.appendChild(it);
|
dropdown.appendChild(it);
|
||||||
@@ -1563,19 +1565,14 @@ async function _fetchDependencies() {
|
|||||||
dropdown.appendChild(source);
|
dropdown.appendChild(source);
|
||||||
}
|
}
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
|
!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);
|
|
||||||
}
|
}
|
||||||
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
|
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (document.querySelector('.cookbook-dep-menu')) {
|
if (document.querySelector('.cookbook-dep-menu')) {
|
||||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_showDepMenu(btn);
|
_showDepMenu(btn);
|
||||||
|
|||||||
+21
-37
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
|
|||||||
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
|
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
|
||||||
import signatureModule from './signature.js';
|
import signatureModule from './signature.js';
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
|
|||||||
let _docAiReplyChoiceMenu = null;
|
let _docAiReplyChoiceMenu = null;
|
||||||
function _closeDocAiReplyChoice() {
|
function _closeDocAiReplyChoice() {
|
||||||
if (_docAiReplyChoiceMenu) {
|
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;
|
_docAiReplyChoiceMenu = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
|
|||||||
const noteInput = menu.querySelector('[data-note-input]');
|
const noteInput = menu.querySelector('[data-note-input]');
|
||||||
setTimeout(() => noteInput?.focus(), 0);
|
setTimeout(() => noteInput?.focus(), 0);
|
||||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
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) => {
|
menu.addEventListener('click', async (ev) => {
|
||||||
const choice = ev.target.closest('[data-mode]');
|
const choice = ev.target.closest('[data-mode]');
|
||||||
if (!choice) return;
|
if (!choice) return;
|
||||||
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||||
const noteHint = (noteInput?.value || '').trim();
|
const noteHint = (noteInput?.value || '').trim();
|
||||||
_closeDocAiReplyChoice();
|
close();
|
||||||
await _aiReply({ mode, noteHint });
|
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 = {}) {
|
async function _aiReply(opts = {}) {
|
||||||
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
|
|||||||
|
|
||||||
function showExportMenu(e, anchorRect) {
|
function showExportMenu(e, anchorRect) {
|
||||||
if (e) e.stopPropagation();
|
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');
|
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
|
// Position from provided rect, clicked element, or fallback to language select
|
||||||
const rect = anchorRect
|
const rect = anchorRect
|
||||||
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
|
|||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'doc-overflow-item';
|
item.className = 'doc-overflow-item';
|
||||||
item.textContent = opt.label;
|
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);
|
menu.appendChild(item);
|
||||||
if (opt._divider) {
|
if (opt._divider) {
|
||||||
const sep = document.createElement('div');
|
const sep = document.createElement('div');
|
||||||
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
|
|||||||
menu.style.top = 'auto';
|
menu.style.top = 'auto';
|
||||||
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
||||||
}
|
}
|
||||||
const close = (ev) => {
|
// Outside-click AND Escape both route through the central esc-stack via
|
||||||
if (ev && ev.type === 'keydown') {
|
// bindMenuDismiss; onClose owns the actual node removal.
|
||||||
if (ev.key !== 'Escape') return;
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportAsHtml() {
|
function exportAsHtml() {
|
||||||
|
|||||||
+6
-11
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
|
|||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import { applyEdgeDock } from './modalSnap.js';
|
import { applyEdgeDock } from './modalSnap.js';
|
||||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
const _acct = () => window.__odysseusActiveEmailAccount
|
const _acct = () => window.__odysseusActiveEmailAccount
|
||||||
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _showEmailMenu(em, anchor, itemEl) {
|
function _showEmailMenu(em, anchor, itemEl) {
|
||||||
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'dropdown email-dropdown show';
|
dropdown.className = 'dropdown email-dropdown show';
|
||||||
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
|||||||
_showRemindSubmenu(em, dropdown);
|
_showRemindSubmenu(em, dropdown);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dropdown.remove();
|
close();
|
||||||
a.action();
|
a.action();
|
||||||
});
|
});
|
||||||
dropdown.appendChild(menuItem);
|
dropdown.appendChild(menuItem);
|
||||||
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
|||||||
|
|
||||||
anchor.appendChild(dropdown);
|
anchor.appendChild(dropdown);
|
||||||
|
|
||||||
const close = (e) => {
|
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
|
||||||
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
|
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
|
// ---- 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.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
|
||||||
item.addEventListener('click', async (e) => {
|
item.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
parentDropdown.remove();
|
dismissOrRemove(parentDropdown);
|
||||||
await _createReplyReminder(em, p.date);
|
await _createReplyReminder(em, p.date);
|
||||||
});
|
});
|
||||||
parentDropdown.appendChild(item);
|
parentDropdown.appendChild(item);
|
||||||
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
|||||||
customItem.innerHTML = '<span>Pick date and time…</span>';
|
customItem.innerHTML = '<span>Pick date and time…</span>';
|
||||||
customItem.addEventListener('click', async (e) => {
|
customItem.addEventListener('click', async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
parentDropdown.remove();
|
dismissOrRemove(parentDropdown);
|
||||||
const tmp = document.createElement('input');
|
const tmp = document.createElement('input');
|
||||||
tmp.type = 'datetime-local';
|
tmp.type = 'datetime-local';
|
||||||
const def = new Date(tomorrow);
|
const def = new Date(tomorrow);
|
||||||
|
|||||||
+24
-43
@@ -23,6 +23,7 @@ import {
|
|||||||
} from './emailLibrary/signatureFold.js';
|
} from './emailLibrary/signatureFold.js';
|
||||||
import { state } from './emailLibrary/state.js';
|
import { state } from './emailLibrary/state.js';
|
||||||
import { collapseSidebarToRail } from './modalSnap.js';
|
import { collapseSidebarToRail } from './modalSnap.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _emailUnreadChipClickWired = false;
|
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.
|
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
||||||
const existing = document.querySelector('.email-card-dropdown');
|
const existing = document.querySelector('.email-card-dropdown');
|
||||||
if (existing && existing._anchor === anchor) {
|
if (existing && existing._anchor === anchor) {
|
||||||
existing.remove();
|
dismissOrRemove(existing);
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Otherwise close any other open dropdown (and clear its anchor's active
|
// Otherwise close any other open dropdown (its own teardown clears its
|
||||||
// state) before opening a fresh one.
|
// anchor's active state) before opening a fresh one.
|
||||||
document.querySelectorAll('.email-card-dropdown').forEach(d => {
|
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||||
if (d._anchor) d._anchor.classList.remove('reader-more-active');
|
|
||||||
d.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'email-card-dropdown';
|
dropdown.className = 'email-card-dropdown';
|
||||||
@@ -5721,8 +5718,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||||||
_showLibRemindSubmenu(em, dropdown);
|
_showLibRemindSubmenu(em, dropdown);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
a.action();
|
a.action();
|
||||||
});
|
});
|
||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
@@ -5735,25 +5731,20 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
|||||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||||
cancelItem.addEventListener('click', (e) => {
|
cancelItem.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
});
|
});
|
||||||
dropdown.appendChild(cancelItem);
|
dropdown.appendChild(cancelItem);
|
||||||
|
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
_fitEmailDropdown(dropdown, rect);
|
_fitEmailDropdown(dropdown, rect);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => {
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
dropdown.remove();
|
||||||
dropdown.remove();
|
anchor.classList.remove('reader-more-active');
|
||||||
anchor.classList.remove('reader-more-active');
|
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _showCardMenu(em, 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');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'email-card-dropdown';
|
dropdown.className = 'email-card-dropdown';
|
||||||
@@ -5918,8 +5909,7 @@ function _showCardMenu(em, anchor) {
|
|||||||
_showLibRemindSubmenu(em, dropdown);
|
_showLibRemindSubmenu(em, dropdown);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
a.action();
|
a.action();
|
||||||
});
|
});
|
||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
@@ -5932,26 +5922,21 @@ function _showCardMenu(em, anchor) {
|
|||||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||||
cancelItem.addEventListener('click', (e) => {
|
cancelItem.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
anchor.classList.remove('reader-more-active');
|
|
||||||
});
|
});
|
||||||
dropdown.appendChild(cancelItem);
|
dropdown.appendChild(cancelItem);
|
||||||
|
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
_fitEmailDropdown(dropdown, rect);
|
_fitEmailDropdown(dropdown, rect);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => {
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
dropdown.remove();
|
||||||
dropdown.remove();
|
anchor.classList.remove('reader-more-active');
|
||||||
anchor.classList.remove('reader-more-active');
|
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
||||||
function _showBulkActionsMenu(anchor) {
|
function _showBulkActionsMenu(anchor) {
|
||||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
@@ -5968,7 +5953,7 @@ function _showBulkActionsMenu(anchor) {
|
|||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
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);
|
dropdown.appendChild(it);
|
||||||
}
|
}
|
||||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
// 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.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
|
||||||
cancelIt.addEventListener('click', (e) => {
|
cancelIt.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dropdown.remove();
|
close();
|
||||||
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
||||||
// documents bulk dropdown.
|
// documents bulk dropdown.
|
||||||
state._selectMode = false;
|
state._selectMode = false;
|
||||||
@@ -5989,13 +5974,9 @@ function _showBulkActionsMenu(anchor) {
|
|||||||
dropdown.appendChild(cancelIt);
|
dropdown.appendChild(cancelIt);
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
_fitEmailDropdown(dropdown, rect);
|
_fitEmailDropdown(dropdown, rect);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => {
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
dropdown.remove();
|
||||||
dropdown.remove();
|
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _updateBulkBar() {
|
function _updateBulkBar() {
|
||||||
|
|||||||
+5
-10
@@ -6,6 +6,7 @@ import uiModule from './ui.js';
|
|||||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { makeWindowDraggable } from './windowDrag.js';
|
import { makeWindowDraggable } from './windowDrag.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -2514,7 +2515,7 @@ export function openGallery() {
|
|||||||
// shares the exact same dropdown style/behaviour.
|
// shares the exact same dropdown style/behaviour.
|
||||||
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
|
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
|
||||||
function _showGalleryBulkMenu(anchor) {
|
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
|
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
|
||||||
// matches every other menu in the app. Positioned fixed at the button.
|
// matches every other menu in the app. Positioned fixed at the button.
|
||||||
const dropdown = document.createElement('div');
|
const dropdown = document.createElement('div');
|
||||||
@@ -2548,17 +2549,11 @@ export function openGallery() {
|
|||||||
const it = document.createElement('div');
|
const it = document.createElement('div');
|
||||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
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);
|
dropdown.appendChild(it);
|
||||||
}
|
}
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
|
||||||
dropdown.remove();
|
|
||||||
document.removeEventListener('click', close, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_bulkActionsBtn?.addEventListener('click', (e) => {
|
_bulkActionsBtn?.addEventListener('click', (e) => {
|
||||||
@@ -2567,7 +2562,7 @@ export function openGallery() {
|
|||||||
// should close it. The outside-click handler explicitly skips clicks on
|
// should close it. The outside-click handler explicitly skips clicks on
|
||||||
// the anchor, so the button itself has to do its own dismiss.
|
// the anchor, so the button itself has to do its own dismiss.
|
||||||
const existing = document.querySelector('.gallery-bulk-menu');
|
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; }
|
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
|
||||||
_showGalleryBulkMenu(e.currentTarget);
|
_showGalleryBulkMenu(e.currentTarget);
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-15
@@ -11,6 +11,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
|||||||
import { snapModalToZone } from './tileManager.js';
|
import { snapModalToZone } from './tileManager.js';
|
||||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||||
import { topToolWindowZ } from './toolWindowZOrder.js';
|
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
|
|||||||
|
|
||||||
function _pickCustomDate() {
|
function _pickCustomDate() {
|
||||||
// Replace the dropdown menu with a small inline picker
|
// 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');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'note-reminder-menu';
|
menu.className = 'note-reminder-menu';
|
||||||
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
|
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
|
||||||
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
|
|||||||
if (typeof dInput.showPicker === 'function') {
|
if (typeof dInput.showPicker === 'function') {
|
||||||
try { dInput.showPicker(); } catch {}
|
try { dInput.showPicker(); } catch {}
|
||||||
}
|
}
|
||||||
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
|
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
|
||||||
if (dInput.value) _setReminder(dInput.value);
|
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); });
|
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.
|
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
|
||||||
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
|
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
|
||||||
function _openNoteCornerMenu(btn) {
|
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 id = btn.dataset.noteId;
|
||||||
const note = _notes.find(n => n.id === id);
|
const note = _notes.find(n => n.id === id);
|
||||||
if (!note) return;
|
if (!note) return;
|
||||||
@@ -4338,14 +4336,9 @@ function _openNoteCornerMenu(btn) {
|
|||||||
const below = window.innerHeight - r.bottom;
|
const below = window.innerHeight - r.bottom;
|
||||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
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;`;
|
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||||
const close = (ev) => {
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
if (ev && menu.contains(ev.target)) return;
|
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
||||||
menu.remove();
|
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||||
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); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _positionNoteMenu(menu, btn, width = 196) {
|
function _positionNoteMenu(menu, btn, width = 196) {
|
||||||
|
|||||||
@@ -1938,6 +1938,7 @@ async function _onSessionListKeydown(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (item.querySelector('.session-rename-input')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sid = item.dataset.sessionId;
|
const sid = item.dataset.sessionId;
|
||||||
const s = sessions.find(x => x.id === sid);
|
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 { sortModelIds } from './modelSort.js';
|
||||||
import { providerLogo } from './providers.js';
|
import { providerLogo } from './providers.js';
|
||||||
import { isAltGrEvent } from './platform.js';
|
import { isAltGrEvent } from './platform.js';
|
||||||
|
import { bindMenuDismiss } from './escMenuStack.js';
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let modalEl = null;
|
let modalEl = null;
|
||||||
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
|
|||||||
if (lbl) lbl.textContent = text;
|
if (lbl) lbl.textContent = text;
|
||||||
if (ico) ico.innerHTML = _apiIconFor(k);
|
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 = () => {
|
const _open = () => {
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
const tRect = trig.getBoundingClientRect();
|
const tRect = trig.getBoundingClientRect();
|
||||||
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
|
|||||||
const above = tRect.top;
|
const above = tRect.top;
|
||||||
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
|
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'; }
|
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); } };
|
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
|
||||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
|
||||||
};
|
};
|
||||||
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
||||||
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
||||||
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
|
|||||||
if (labelEl) labelEl.textContent = lbl;
|
if (labelEl) labelEl.textContent = lbl;
|
||||||
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
|
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 = () => {
|
const _openMenu = () => {
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
// Drop-up when there's not enough room below the trigger.
|
// Drop-up when there's not enough room below the trigger.
|
||||||
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
|
|||||||
} else {
|
} else {
|
||||||
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
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); } };
|
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
|
||||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
|
||||||
};
|
};
|
||||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
||||||
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
||||||
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
|
|||||||
addBtn.parentElement.style.position = 'relative';
|
addBtn.parentElement.style.position = 'relative';
|
||||||
addBtn.parentElement.classList.add('uf-add-anchor');
|
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;
|
let _menuEl = null;
|
||||||
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
|
let _closeMenu = () => {};
|
||||||
addBtn.addEventListener('click', (e) => {
|
addBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (_menuEl) { _closeMenu(); return; }
|
if (_menuEl) { _closeMenu(); return; }
|
||||||
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
|
|||||||
showForm(k, 'new');
|
showForm(k, 'new');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
|
||||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import uiModule from './ui.js';
|
import uiModule from './ui.js';
|
||||||
import * as spinnerModule from './spinner.js';
|
import * as spinnerModule from './spinner.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API = window.location.origin;
|
const API = window.location.origin;
|
||||||
let skills = [];
|
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
|
// Kebab dropdown for a collapsed skill card — same actions + icons as the
|
||||||
// expanded footer (Publish/Unpublish · Edit · Delete).
|
// expanded footer (Publish/Unpublish · Edit · Delete).
|
||||||
function _openSkillMenu(btn, card, sk, name, isPublished) {
|
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');
|
const menu = document.createElement('div');
|
||||||
menu.className = 'skill-kebab-menu';
|
menu.className = 'skill-kebab-menu';
|
||||||
const mk = (paths, label, opts, onClick) => {
|
const mk = (paths, label, opts, onClick) => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
|
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
|
||||||
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
|
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);
|
menu.appendChild(item);
|
||||||
};
|
};
|
||||||
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
|
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.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) => {
|
selItem.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menu.remove();
|
close();
|
||||||
if (!_selectMode) _enterSelectMode();
|
if (!_selectMode) _enterSelectMode();
|
||||||
_selectedNames.add(name);
|
_selectedNames.add(name);
|
||||||
renderSkillsList();
|
renderSkillsList();
|
||||||
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
|||||||
const cancelItem = document.createElement('button');
|
const cancelItem = document.createElement('button');
|
||||||
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
|
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.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);
|
menu.appendChild(cancelItem);
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
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.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
|
||||||
menu.style.overflowY = 'auto';
|
menu.style.overflowY = 'auto';
|
||||||
}
|
}
|
||||||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
|
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
|
||||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cards for the agent's built-in tool capabilities (from
|
// 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 { makeWindowDraggable } from './windowDrag.js';
|
||||||
import { sortModelIds } from './modelSort.js';
|
import { sortModelIds } from './modelSort.js';
|
||||||
import { ordinalSuffix } from './util/ordinal.js';
|
import { ordinalSuffix } from './util/ordinal.js';
|
||||||
|
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
|
|
||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _open = false;
|
let _open = false;
|
||||||
@@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) {
|
|||||||
|
|
||||||
function _showTaskDropdown(anchor, items) {
|
function _showTaskDropdown(anchor, items) {
|
||||||
// Remove any existing dropdown
|
// Remove any existing dropdown
|
||||||
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
|
document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
|
||||||
const dd = document.createElement('div');
|
const dd = document.createElement('div');
|
||||||
dd.className = 'task-dropdown';
|
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;';
|
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('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
|
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);
|
dd.appendChild(btn);
|
||||||
});
|
});
|
||||||
document.body.appendChild(dd);
|
document.body.appendChild(dd);
|
||||||
@@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) {
|
|||||||
dd.style.top = top + 'px';
|
dd.style.top = top + 'px';
|
||||||
dd.style.left = left + 'px';
|
dd.style.left = left + 'px';
|
||||||
const openedAt = performance.now();
|
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
|
// Ignore any clicks that occur within 250ms of the open (covers touch
|
||||||
// "ghost click" duplicates that were firing right after pointerup and
|
// "ghost click" duplicates that were firing right after pointerup and
|
||||||
// removing the dropdown before the user could see it).
|
// removing the dropdown before the user could see it) — treat as inside.
|
||||||
if (performance.now() - openedAt < 250) return;
|
if (performance.now() - openedAt < 250) return false;
|
||||||
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
|
return !dd.contains(ev.target);
|
||||||
};
|
});
|
||||||
// requestAnimationFrame so the listener is registered AFTER the current
|
|
||||||
// pointer/click event cycle has finished bubbling.
|
|
||||||
requestAnimationFrame(() => document.addEventListener('click', close));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Presets ----
|
// ---- 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
|
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():
|
def test_registered_everywhere():
|
||||||
# TOOL_TAGS gate (serializer rejects unknown tools)
|
# TOOL_TAGS gate (serializer rejects unknown tools)
|
||||||
assert "ask_user" in TOOL_TAGS
|
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."""
|
still appear on Windows even though the AWQ variant is hidden."""
|
||||||
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
|
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
|
||||||
assert "Qwen/Qwen2.5-3B-Instruct" in names
|
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