mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-08 11:56:59 +00:00
Compare commits
17 Commits
d36879bd50
...
e8175c9535
| Author | SHA1 | Date | |
|---|---|---|---|
| e8175c9535 | |||
| bd9149f79a | |||
| fef08ed114 | |||
| 7e5db9a3c6 | |||
| 2f246c7779 | |||
| 8ec27fd903 | |||
| b57989f08c | |||
| 91bba117c1 | |||
| 4c82e4a172 | |||
| b899095f18 | |||
| 888e25624f | |||
| c062c27648 | |||
| 93ec7cbb52 | |||
| c12b8ab6c9 | |||
| e812a29233 | |||
| ca4973c41f | |||
| 91b4171b3f |
@@ -86,6 +86,7 @@ Bundled in `static/fonts/`:
|
||||
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
||||
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
||||
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
||||
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
|
||||
|
||||
## Python dependencies
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Helpers for keeping sensitive data out of logs.
|
||||
|
||||
Endpoint URLs configured by admins can embed credentials in the userinfo
|
||||
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
|
||||
raw leaks those secrets, so route/diagnostic logs run URLs through
|
||||
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
|
||||
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def redact_url(url: str) -> str:
|
||||
"""Return a URL safe for logs by removing userinfo and query/fragment.
|
||||
|
||||
Keeps scheme, host, port and path so logs stay useful for debugging.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
host = parsed.hostname or ""
|
||||
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
|
||||
host = f"[{host}]"
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||||
except Exception:
|
||||
return "<endpoint>"
|
||||
+14
-1
@@ -15,7 +15,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
|
||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||
Use that for the first login, then change it in **Settings**.
|
||||
|
||||
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
|
||||
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
|
||||
pull request guidelines.
|
||||
|
||||
### Docker (recommended)
|
||||
@@ -250,6 +250,19 @@ python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||
3.11+ version) for the venv step.
|
||||
|
||||
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
|
||||
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
|
||||
alone leaves the native Windows server on loopback. Pass the launcher's
|
||||
`-BindHost` flag instead:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
|
||||
```
|
||||
|
||||
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
|
||||
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
|
||||
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
|
||||
|
||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||
model downloads and the agent shell tool, also install
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
||||
with Reserved Font Name OpenDyslexic.
|
||||
Copyright (c) 12/2012 - 2019
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -29,6 +29,7 @@ from routes.document_helpers import _owner_session_filter
|
||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
||||
from core.database import Document as DBDocument, ModelEndpoint
|
||||
from core.log_safety import redact_url
|
||||
from routes.research_routes import _resolve_research_endpoint
|
||||
from routes.model_routes import _visible_models
|
||||
from routes.chat_helpers import (
|
||||
@@ -930,7 +931,7 @@ def setup_chat_routes(
|
||||
if effective_do_research:
|
||||
_r_ep, _r_model, _r_headers = _resolve_research_endpoint(sess)
|
||||
_auth_keys = list(_r_headers.keys()) if _r_headers else []
|
||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={_r_ep}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||
logger.info(f"Research endpoint resolved: model={_r_model}, endpoint={redact_url(_r_ep)}, auth_keys={_auth_keys}, sess_headers_keys={list(sess.headers.keys()) if isinstance(sess.headers, dict) else type(sess.headers)}")
|
||||
|
||||
# Clarification round: only for very short/vague queries on first research message.
|
||||
# Skip in compare mode — each pane is a fresh session, so every one would
|
||||
|
||||
@@ -18,6 +18,7 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse, urlunparse
|
||||
|
||||
from core.log_safety import redact_url
|
||||
from fastapi import APIRouter, Query, Depends, Response, HTTPException
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
@@ -689,15 +690,24 @@ def _delete_contact(uid: str) -> bool:
|
||||
url = _resolve_resource_url(uid)
|
||||
auth = (cfg["username"], cfg["password"]) if cfg["username"] else None
|
||||
r = httpx.delete(url, auth=auth, timeout=10)
|
||||
if r.status_code in (200, 204):
|
||||
_contact_cache["fetched_at"] = None
|
||||
return True
|
||||
if r.status_code == 404:
|
||||
# Resource not found at the resolved URL. With href resolution
|
||||
# this should be rare (genuinely already deleted). Invalidate
|
||||
# the cache and report success so the UI doesn't keep a ghost.
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — treating as already gone")
|
||||
if r.status_code in (200, 204, 404):
|
||||
# Invalidate cache so the next fetch sees the server truth.
|
||||
_contact_cache["fetched_at"] = None
|
||||
# Verify: force a fresh fetch and check the UID is actually gone.
|
||||
# A 404 on the guessed URL ({uid}.vcf) can mean the contact
|
||||
# lives at a different resource URL — the DELETE missed it but
|
||||
# we'd silently report success. This check catches that.
|
||||
fresh = _fetch_contacts(force=True)
|
||||
still_there = any(c.get("uid") == uid for c in fresh)
|
||||
if still_there:
|
||||
logger.warning(
|
||||
f"CardDAV DELETE reported success for {uid} "
|
||||
f"but UID still present after re-fetch — "
|
||||
f"resource URL may differ from {redact_url(url)}"
|
||||
)
|
||||
return False
|
||||
if r.status_code == 404:
|
||||
logger.info(f"CardDAV DELETE 404 for {uid} — already gone")
|
||||
return True
|
||||
logger.warning(f"CardDAV DELETE returned {r.status_code}: {r.text[:200]}")
|
||||
return False
|
||||
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from core.database import Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,6 +79,8 @@ def _verify_doc_owner(db, doc: Document, user: str):
|
||||
the session join for any not-yet-backfilled legacy row.
|
||||
"""
|
||||
if user is None:
|
||||
if _auth_disabled():
|
||||
return # Single-user / no-auth mode: allow access
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if doc.owner is not None:
|
||||
if doc.owner != user:
|
||||
@@ -104,7 +107,6 @@ def _owner_session_filter(q, user):
|
||||
by the time this filter is live there are no NULL-owner rows to leak;
|
||||
we therefore match the owner strictly for authenticated callers."""
|
||||
if not user:
|
||||
from src.auth_helpers import _auth_disabled
|
||||
if user == "" or _auth_disabled():
|
||||
return q
|
||||
return q.filter(False)
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File,
|
||||
from sqlalchemy import case, func, or_
|
||||
from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -388,7 +388,8 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if not user:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if not _auth_disabled():
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v2 review HIGH-9: raise 403 explicitly when the caller
|
||||
# can't see this session, instead of returning [] which the
|
||||
# UI treats identically to "no docs" and silently masks
|
||||
|
||||
+12
-1
@@ -44,6 +44,17 @@ from routes.email_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Recovers a `[{"action": ...}, ...]` JSON array from raw LLM output when the
|
||||
# fenced-block strip leaves nothing usable. Runs on model output influenced by
|
||||
# untrusted email bodies, so it must not backtrack: the object content class is
|
||||
# `[^{}]` (brace-delimited, greedy) rather than the old `[^[\]]*?` lazy runs,
|
||||
# which exploded exponentially on inputs like `[{"action"},{` + `}},{{` * N
|
||||
# (CodeQL py/redos #198).
|
||||
_CAL_ACTION_ARRAY_RE = re.compile(
|
||||
r'\[\s*\{[^{}]*"action"[^{}]*\}\s*(?:,\s*\{[^{}]*\}\s*)*\]',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _owner_for_email_account(account_id: str | None) -> str:
|
||||
if not account_id:
|
||||
@@ -558,7 +569,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
cal_extract = _strip_think(_raw_original)
|
||||
cal_extract = re.sub(r"^```(?:json)?\s*|\s*```$", "", cal_extract, flags=re.MULTILINE).strip()
|
||||
if not cal_extract and _raw_original:
|
||||
matches = list(re.finditer(r'\[\s*\{[^[\]]*?"action"[^[\]]*?\}\s*(?:,\s*\{[^[\]]*?\}\s*)*\]', _raw_original, re.DOTALL))
|
||||
matches = list(_CAL_ACTION_ARRAY_RE.finditer(_raw_original))
|
||||
if matches:
|
||||
cal_extract = matches[-1].group()
|
||||
logger.info(f"[cal-extract] uid={uid.decode() if isinstance(uid, bytes) else uid} folder={_folder} subj={subject[:50]!r} raw_len={len(cal_extract)} orig_len={len(_raw_original)} raw={cal_extract[:800]!r}")
|
||||
|
||||
+3
-14
@@ -17,6 +17,7 @@ from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Respon
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import StreamingResponse
|
||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||
from core.log_safety import redact_url as _redact_url_for_log
|
||||
from core.middleware import require_admin
|
||||
from src.llm_core import _detect_provider, _host_match, ANTHROPIC_MODELS
|
||||
from src.tls_overrides import llm_verify
|
||||
@@ -582,18 +583,6 @@ def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
|
||||
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
|
||||
def _redact_url_for_log(url: str) -> str:
|
||||
"""Return a URL safe for logs by removing userinfo and query/fragment."""
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||||
except Exception:
|
||||
return "<endpoint>"
|
||||
|
||||
|
||||
def _is_discovery_only_provider(provider: str) -> bool:
|
||||
return provider == "chatgpt-subscription"
|
||||
|
||||
@@ -810,9 +799,9 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||
except Exception as e:
|
||||
if api_key:
|
||||
logger.warning(f"Failed to probe {url} with API key: {e}")
|
||||
logger.warning("Failed to probe %s with API key: %s", _redact_url_for_log(url), e)
|
||||
return []
|
||||
logger.warning(f"Failed to probe {url}: {e}")
|
||||
logger.warning("Failed to probe %s: %s", _redact_url_for_log(url), e)
|
||||
|
||||
# Older Ollama builds and some proxies expose native /api/tags even when
|
||||
# the OpenAI-compatible /v1/models path is unavailable.
|
||||
|
||||
@@ -335,10 +335,11 @@ async def dispatch_reminder(
|
||||
# Loud diagnostic so we can see WHY a reminder didn't send (the
|
||||
# previous "silently no-op when cfg has no smtp_host" was invisible).
|
||||
logger.info(
|
||||
f"dispatch_reminder[email] note_id={note_id} owner={owner!r} "
|
||||
f"smtp_host={cfg.get('smtp_host')!r} smtp_user={cfg.get('smtp_user')!r} "
|
||||
f"from={from_addr!r} recipient={recipient!r} "
|
||||
f"account_name={cfg.get('account_name')!r}"
|
||||
"dispatch_reminder[email] note_id=%s owner=%r "
|
||||
"has_smtp_host=%s has_smtp_user=%s has_from=%s has_recipient=%s",
|
||||
note_id, owner,
|
||||
bool(cfg.get("smtp_host")), bool(cfg.get("smtp_user")),
|
||||
bool(from_addr), bool(recipient),
|
||||
)
|
||||
missing = []
|
||||
if not cfg.get("smtp_host"):
|
||||
|
||||
@@ -538,6 +538,32 @@ def _powershell_exe():
|
||||
path so we don't depend on a particular PATH ordering."""
|
||||
return shutil.which("pwsh") or shutil.which("powershell") or "powershell"
|
||||
|
||||
def _powershell_encoded_for_ssh(script: str):
|
||||
"""Run a PowerShell script on a remote Windows host over SSH.
|
||||
|
||||
Nested quotes in powershell -Command break when passed through Windows
|
||||
OpenSSH's cmd wrapper; -EncodedCommand avoids that.
|
||||
"""
|
||||
import base64
|
||||
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||
return _run(f"powershell -NoProfile -EncodedCommand {encoded}")
|
||||
|
||||
|
||||
def _probe_remote_platform():
|
||||
"""Best-effort OS detection over SSH when the caller didn't pass platform."""
|
||||
out = _run("echo %OS%")
|
||||
if out and "Windows_NT" in out:
|
||||
return "windows"
|
||||
uname = (_run(["uname", "-s"]) or "").strip().lower()
|
||||
if uname == "darwin":
|
||||
# Mac uses the linux detection path (_detect_apple_silicon over SSH).
|
||||
return "linux"
|
||||
if uname == "linux":
|
||||
out = _run("test -d /data/data/com.termux && echo termux || echo linux")
|
||||
if out and "termux" in out:
|
||||
return "termux"
|
||||
return "linux"
|
||||
|
||||
|
||||
def _detect_windows():
|
||||
"""Detect Windows hardware via PowerShell/WMI.
|
||||
@@ -600,9 +626,8 @@ def _detect_windows():
|
||||
"""
|
||||
)
|
||||
if _remote_host:
|
||||
# Remote: ship a single command string over SSH. The remote shell parses
|
||||
# the quoting; PowerShell on the far side runs the -Command payload.
|
||||
out = _run(f'powershell -Command "{ps_cmd}"')
|
||||
# Remote: use -EncodedCommand so OpenSSH/cmd quoting does not break the script.
|
||||
out = _powershell_encoded_for_ssh(ps_cmd.strip())
|
||||
else:
|
||||
# Local: pass a LIST argv straight to subprocess so the OS hands ps_cmd
|
||||
# to PowerShell verbatim — no fragile string-level quote escaping. Prefer
|
||||
@@ -773,6 +798,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"""
|
||||
global _remote_host, _remote_port, _remote_platform
|
||||
|
||||
if host and not platform:
|
||||
_remote_host = host
|
||||
_remote_port = ssh_port or None
|
||||
platform = _probe_remote_platform()
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
|
||||
cache_key = _cache_key(host, ssh_port, platform)
|
||||
now = time.time()
|
||||
if not fresh and cache_key in _cache_by_host:
|
||||
@@ -793,8 +825,8 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
# If Windows detection failed, return error
|
||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
||||
# SSH may work while the PowerShell hardware probe still fails.
|
||||
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||
_remote_host = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
|
||||
+24
-6
@@ -3215,9 +3215,12 @@ async def stream_agent_loop(
|
||||
f'data: {json.dumps({"type": "ui_control", "data": result})}\n\n'
|
||||
)
|
||||
|
||||
# ask_user: the agent posed a multiple-choice question. Emit it so the
|
||||
# frontend renders clickable options, then end the turn (below) and
|
||||
# wait — the user's pick becomes the next message.
|
||||
# ask_user: remember the payload now, but emit the interactive event
|
||||
# only *after* tool_output below. Emitting it before tool_output let
|
||||
# the subsequent tool-card rewrite/scroll push the choices out of
|
||||
# view. The payload is also copied into the persisted tool event so
|
||||
# history reload can reconstruct an unanswered card.
|
||||
_pending_ask_user_event = None
|
||||
if "ask_user" in result:
|
||||
# The question lives in the tool args. ChatMessage.to_dict()
|
||||
# replays only role+content to the model next turn — tool_event
|
||||
@@ -3232,9 +3235,7 @@ async def stream_agent_loop(
|
||||
_auq_delta = ("\n\n" if full_response.strip() else "") + _auq_q
|
||||
full_response += _auq_delta
|
||||
yield 'data: ' + json.dumps({"delta": _auq_delta}) + '\n\n'
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "ask_user", "data": result["ask_user"]})}\n\n'
|
||||
)
|
||||
_pending_ask_user_event = _auq
|
||||
_awaiting_user = True
|
||||
|
||||
# update_plan: agent wrote back to the plan (ticked a step / revised).
|
||||
@@ -3289,6 +3290,10 @@ async def stream_agent_loop(
|
||||
|
||||
# Emit tool_output (include ui_event data if present)
|
||||
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
||||
if _pending_ask_user_event:
|
||||
# Keep enough state in the streamed tool result for alternate
|
||||
# clients to render the prompt without depending on event order.
|
||||
tool_output_data["ask_user"] = _pending_ask_user_event
|
||||
if "ui_event" in result:
|
||||
tool_output_data["ui_event"] = result["ui_event"]
|
||||
for k in (
|
||||
@@ -3319,6 +3324,14 @@ async def stream_agent_loop(
|
||||
tool_output_data["diff"] = result["diff"]
|
||||
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
||||
|
||||
# This must be the final UI event for ask_user: the frontend appends
|
||||
# the card below the now-settled tool node and cancels any between-
|
||||
# round spinner. The turn ends after the current tool batch.
|
||||
if _pending_ask_user_event:
|
||||
yield (
|
||||
f'data: {json.dumps({"type": "ask_user", "data": _pending_ask_user_event})}\n\n'
|
||||
)
|
||||
|
||||
# Native document tools open in the editor + carry the REAL doc id.
|
||||
# Emit a doc_update so the frontend opens/activates it and sends it
|
||||
# back as active_doc_id next turn (otherwise the agent can't "see"
|
||||
@@ -3376,6 +3389,11 @@ async def stream_agent_loop(
|
||||
# this the diff shows live but vanishes from saved history.
|
||||
if result.get("diff"):
|
||||
tool_event["diff"] = result["diff"]
|
||||
if _pending_ask_user_event:
|
||||
# Persist the structured question with the tool event. On a
|
||||
# reload, chatRenderer can restore the card; a later user
|
||||
# message removes it as answered.
|
||||
tool_event["ask_user"] = _pending_ask_user_event
|
||||
tool_events.append(tool_event)
|
||||
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
||||
_effectful_used = True
|
||||
|
||||
+155
-28
@@ -345,43 +345,102 @@ def _normalize_ollama_url(url: str) -> str:
|
||||
return base.rstrip("/") + "/chat"
|
||||
|
||||
|
||||
def _ollama_normalize_tool_messages(messages: List[Dict]) -> List[Dict]:
|
||||
def _ollama_normalize_messages(messages: List[Dict]) -> List[Dict]:
|
||||
"""Adapt Odysseus' canonical OpenAI-style messages to native Ollama /api/chat.
|
||||
|
||||
Odysseus carries assistant tool calls in the OpenAI shape, where
|
||||
`function.arguments` is a JSON *string*. Native Ollama expects it to be a
|
||||
JSON *object*; given the string it fails the whole request with HTTP 400
|
||||
"Value looks like object, but can't find closing '}' symbol", which aborts
|
||||
every follow-up (tool-result) round. Parse the arguments back into an object
|
||||
here, on a shallow copy, leaving non-tool messages untouched. The opaque
|
||||
Gemini `extra_content` (thought_signature) is dropped — it is meaningless to
|
||||
Ollama and only matters when the conversation is replayed to Gemini.
|
||||
Two shape mismatches silently break requests:
|
||||
|
||||
1. Tool calls: Odysseus carries `function.arguments` as a JSON *string*.
|
||||
Native Ollama expects a JSON *object* and rejects the string form with
|
||||
HTTP 400 ("Value looks like object, but can't find closing '}' symbol"),
|
||||
aborting every follow-up (tool-result) round. Parse the arguments back
|
||||
into an object here, on a shallow copy, leaving non-tool messages
|
||||
untouched. The opaque Gemini `extra_content` (thought_signature) is
|
||||
dropped — it is meaningless to Ollama and only matters when the
|
||||
conversation is replayed to Gemini.
|
||||
|
||||
2. Images (issue #4723): Odysseus carries multimodal user content as an
|
||||
OpenAI-style list ``[{type: "text", ...}, {type: "image_url",
|
||||
image_url: {url: "data:image/...;base64,XXX"}}, ...]``. Native Ollama
|
||||
does not accept a list for ``content`` — it wants ``content`` as a
|
||||
string plus a separate ``images`` array of raw base64 strings (no
|
||||
``data:`` prefix). Without this conversion the image blocks pass
|
||||
through untouched, the vision-capable model never sees the picture,
|
||||
and the user gets "I can't see any image" even though the request
|
||||
succeeded.
|
||||
"""
|
||||
out: List[Dict] = []
|
||||
for m in messages or []:
|
||||
tcs = m.get("tool_calls") if isinstance(m, dict) else None
|
||||
if not tcs:
|
||||
if not isinstance(m, dict):
|
||||
out.append(m)
|
||||
continue
|
||||
new_calls = []
|
||||
for tc in tcs:
|
||||
fn = tc.get("function") or {}
|
||||
args = fn.get("arguments")
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args) if args.strip() else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
|
||||
if tc.get("id"):
|
||||
call["id"] = tc["id"]
|
||||
new_calls.append(call)
|
||||
|
||||
nm = dict(m)
|
||||
nm["tool_calls"] = new_calls
|
||||
|
||||
# 1. Tool-call argument strings -> objects.
|
||||
tcs = nm.get("tool_calls")
|
||||
if tcs:
|
||||
new_calls = []
|
||||
for tc in tcs:
|
||||
fn = tc.get("function") or {}
|
||||
args = fn.get("arguments")
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args) if args.strip() else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
call: Dict = {"function": {"name": fn.get("name", ""), "arguments": args or {}}}
|
||||
if tc.get("id"):
|
||||
call["id"] = tc["id"]
|
||||
new_calls.append(call)
|
||||
nm["tool_calls"] = new_calls
|
||||
|
||||
# 2. Multimodal content list -> native content string + images array.
|
||||
content = nm.get("content")
|
||||
if isinstance(content, list):
|
||||
text_parts: List[str] = []
|
||||
images: List[str] = list(nm.get("images") or [])
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype == "text":
|
||||
t = block.get("text")
|
||||
if t:
|
||||
text_parts.append(str(t))
|
||||
elif btype == "image_url":
|
||||
url = (block.get("image_url") or {}).get("url", "")
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith("data:"):
|
||||
# Strip the ``data:[...];base64,`` prefix — native
|
||||
# Ollama wants only the base64 bytes.
|
||||
_, _, b64 = url.partition(",")
|
||||
if b64:
|
||||
images.append(b64)
|
||||
else:
|
||||
# Native Ollama images[] is base64-only; it does
|
||||
# not fetch HTTP URLs. Skip unsupported schemes
|
||||
# rather than sending a non-base64 string that the
|
||||
# model silently ignores.
|
||||
logger.warning(
|
||||
"Skipping non-data image_url (Ollama images[] "
|
||||
"requires base64): %s",
|
||||
url[:80],
|
||||
)
|
||||
nm["content"] = "\n".join(text_parts).strip()
|
||||
if images:
|
||||
nm["images"] = images
|
||||
|
||||
out.append(nm)
|
||||
return out
|
||||
|
||||
|
||||
# Backward-compatible alias for callers/tests that imported the older name
|
||||
# (it only handled tool messages originally — issue #4723 broadened scope).
|
||||
_ollama_normalize_tool_messages = _ollama_normalize_messages
|
||||
|
||||
|
||||
def _build_ollama_payload(
|
||||
model: str,
|
||||
messages: List[Dict],
|
||||
@@ -404,7 +463,7 @@ def _build_ollama_payload(
|
||||
"""
|
||||
payload: Dict = {
|
||||
"model": model,
|
||||
"messages": _ollama_normalize_tool_messages(messages),
|
||||
"messages": _ollama_normalize_messages(messages),
|
||||
"stream": stream,
|
||||
}
|
||||
options: Dict = {}
|
||||
@@ -618,6 +677,8 @@ def _detect_provider(url: str) -> str:
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(url):
|
||||
return "copilot"
|
||||
if _host_match(url, "mistral.ai"):
|
||||
return "mistral"
|
||||
return "openai"
|
||||
|
||||
|
||||
@@ -906,10 +967,17 @@ def _anthropic_rejects_temperature(model: str) -> bool:
|
||||
return False
|
||||
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
||||
|
||||
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
||||
# API accepts "high", "medium", "low", "none" — see
|
||||
# https://docs.mistral.ai/capabilities/reasoning/. Override via env var
|
||||
# ODYSSEUS_MISTRAL_REASONING_EFFORT (e.g. set to "medium" for cheaper chat).
|
||||
_MISTRAL_REASONING_EFFORT = os.getenv("ODYSSEUS_MISTRAL_REASONING_EFFORT", "high")
|
||||
|
||||
# Models that support structured thinking — may output </think> without opening tag
|
||||
_THINKING_MODEL_PATTERNS = (
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
|
||||
"m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||
"magistral", "mistral-small", "mistral-medium",
|
||||
)
|
||||
|
||||
def _supports_thinking(model: str) -> bool:
|
||||
@@ -919,6 +987,38 @@ def _supports_thinking(model: str) -> bool:
|
||||
m = model.lower()
|
||||
return any(p in m for p in _THINKING_MODEL_PATTERNS)
|
||||
|
||||
def _normalize_mistral_content(content):
|
||||
"""Mistral returns content as a structured array when reasoning is on:
|
||||
[{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
|
||||
{"type": "text", "text": "...final answer..."}]
|
||||
Convert to (text, thinking) tuple of plain strings. Pass through strings
|
||||
unchanged so non-Mistral OpenAI-compat endpoints are unaffected.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content, ""
|
||||
if not isinstance(content, list):
|
||||
return "", ""
|
||||
text_parts = []
|
||||
thinking_parts = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype == "text":
|
||||
t = block.get("text", "")
|
||||
if t:
|
||||
text_parts.append(t)
|
||||
elif btype == "thinking":
|
||||
inner = block.get("thinking", [])
|
||||
if isinstance(inner, list):
|
||||
for tb in inner:
|
||||
if isinstance(tb, dict) and tb.get("text"):
|
||||
thinking_parts.append(tb["text"])
|
||||
elif isinstance(inner, str):
|
||||
thinking_parts.append(inner)
|
||||
return "".join(text_parts), "".join(thinking_parts)
|
||||
|
||||
|
||||
def _convert_openai_content_to_anthropic(content):
|
||||
"""Convert OpenAI multimodal content blocks to Anthropic format.
|
||||
|
||||
@@ -1441,6 +1541,8 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||
@@ -1456,7 +1558,16 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
msg = data["choices"][0]["message"]
|
||||
response = msg.get("content") or msg.get("reasoning_content") or ""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Mistral structured content — extract thinking + text
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
response = thinking_part + "\n\n" + (text_part or "")
|
||||
else:
|
||||
response = text_part or msg.get("reasoning_content") or ""
|
||||
else:
|
||||
response = content or msg.get("reasoning_content") or ""
|
||||
_set_cached_response(cache_key, response)
|
||||
return response
|
||||
except Exception:
|
||||
@@ -1638,6 +1749,8 @@ async def llm_call_async(
|
||||
# Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm.
|
||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||
payload["think"] = False
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
_apply_local_cache_affinity(payload, url, session_id)
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
@@ -1756,6 +1869,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
payload[tok_key] = max_tokens
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||
# activates thinking mode and returns structured reasoning_content.
|
||||
# Effort level is configurable via ODYSSEUS_MISTRAL_REASONING_EFFORT
|
||||
# (high / medium / low / none); default "high".
|
||||
if provider == "mistral" and _supports_thinking(model):
|
||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3,
|
||||
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside
|
||||
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
|
||||
@@ -2134,9 +2253,17 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
# Text content
|
||||
# Reasoning tokens (VLLM --reasoning-parser, e.g. Qwen3/DeepSeek-R1, Nemotron). vLLM 0.20.2 / NIM emit the field as `reasoning`; older builds use `reasoning_content`. Some OpenAI-compatible Ollama builds use `thinking`.
|
||||
reasoning = delta.get("reasoning_content") or delta.get("reasoning") or delta.get("thinking") or ""
|
||||
content = delta.get("content") or ""
|
||||
# Mistral structured content: content is a list of typed blocks
|
||||
# ({"type": "thinking", ...}, {"type": "text", ...}). Split into
|
||||
# reasoning + text so thinking streams into the thinking panel.
|
||||
if isinstance(content, list):
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
reasoning = (reasoning + thinking_part) if reasoning else thinking_part
|
||||
content = text_part
|
||||
if reasoning:
|
||||
yield _stream_delta_event(reasoning, thinking=True)
|
||||
content = delta.get("content") or ""
|
||||
if content:
|
||||
content = re.sub(r"<mm:think(\s+[^>]*)?>", r"<think\1>", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"</mm:think>", "</think>", content, flags=re.IGNORECASE)
|
||||
|
||||
@@ -1667,7 +1667,7 @@ class TaskScheduler:
|
||||
msg["X-Odysseus-Ref"] = str(task.id)
|
||||
msg.set_content(result or "")
|
||||
_send_smtp_message(cfg, from_addr, [to_addr], msg.as_string(), timeout=30)
|
||||
logger.info("Task %s emailed result to %s (%sb)", task.id, to_addr, len(result or ""))
|
||||
logger.info("Task %s emailed result (recipient_set=%s, %sb)", task.id, bool(to_addr), len(result or ""))
|
||||
except Exception as e:
|
||||
logger.error("Task %s email delivery failed: %s", task.id, e, exc_info=True)
|
||||
raise
|
||||
@@ -2029,7 +2029,7 @@ class TaskScheduler:
|
||||
# silent SMTP failure is easier to spot in the logs.
|
||||
logger.info(
|
||||
f"Task {task.id} delivered via MCP tool {tool_name} "
|
||||
f"(to={recipient or '<unset>'}, body={body_len}b, reply={stdout[:200]!r})"
|
||||
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
||||
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||
"search_chats": "Search past session transcripts across chats.",
|
||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Omit `multi`/keep it false unless the question explicitly permits choosing multiple options. Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||
|
||||
+8
-2
@@ -467,7 +467,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"question": {"type": "string", "description": "The question to ask. Be specific and self-contained."},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "2-6 mutually exclusive choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
|
||||
"description": "2-6 choices. Each is an object with a short `label` and an optional `description` explaining the trade-off.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -477,7 +477,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"required": ["label"]
|
||||
}
|
||||
},
|
||||
"multi": {"type": "boolean", "description": "Set true to let the user select multiple options instead of one. Default false."}
|
||||
"multi": {"type": "boolean", "description": "Set true ONLY when the question explicitly allows choosing more than one option. Otherwise omit it or set false. Default false."}
|
||||
},
|
||||
"required": ["question", "options"]
|
||||
}
|
||||
@@ -1406,6 +1406,12 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "ask_teacher":
|
||||
content = args.get("model", "auto") + "\n" + args.get("problem", "")
|
||||
elif tool_type == "ask_user":
|
||||
# Keep user-facing labels readable in the tool trace. The outer SSE
|
||||
# JSON encoder will escape them for transport and JSON.parse restores
|
||||
# them once; pre-escaping here caused literal ``\u00f1`` sequences to
|
||||
# remain visible in the debug panel.
|
||||
content = json.dumps(args, ensure_ascii=False)
|
||||
else:
|
||||
content = json.dumps(args)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+16
-2
@@ -76,7 +76,7 @@
|
||||
}
|
||||
// Apply font early
|
||||
if (t && t.font) {
|
||||
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif"};
|
||||
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif",opendyslexic:"'OpenDyslexic', sans-serif"};
|
||||
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
|
||||
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
|
||||
}
|
||||
@@ -84,6 +84,12 @@
|
||||
if (t && t.density && t.density !== 'comfortable') {
|
||||
document.documentElement.classList.add('density-' + t.density);
|
||||
}
|
||||
// Apply UI text-size scale early (global accessibility pref, independent
|
||||
// of the active theme) so there's no flash on load.
|
||||
try {
|
||||
var _us = localStorage.getItem('odysseus-ui-scale');
|
||||
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
|
||||
} catch(e){}
|
||||
// Apply background pattern on body once available
|
||||
if (t && t.bgPattern && t.bgPattern !== 'none') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -581,6 +587,7 @@
|
||||
<option value="mono">Monospace</option>
|
||||
<option value="sans">Sans-serif</option>
|
||||
<option value="serif">Serif</option>
|
||||
<option value="opendyslexic">OpenDyslexic (dyslexia-friendly)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group">
|
||||
@@ -591,6 +598,13 @@
|
||||
<option value="spacious">Spacious</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Text size</label>
|
||||
<select id="theme-text-size-select" class="theme-fd-select" aria-label="Text size">
|
||||
<option value="100">Default</option>
|
||||
<option value="125">Larger</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group" id="theme-frosted-group">
|
||||
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
|
||||
<label class="admin-switch" style="margin-top:4px;">
|
||||
@@ -1318,7 +1332,7 @@
|
||||
|
||||
<!-- Cookbook Modal -->
|
||||
<div id="cookbook-modal" class="modal hidden">
|
||||
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);">
|
||||
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); background: var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
|
||||
<button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook">✖</button>
|
||||
|
||||
+10
-9
@@ -5,6 +5,7 @@
|
||||
import uiModule from './ui.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { attachColorPicker } from './colorPicker.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||
_trashIcon, _moreIcon, _bellIcon,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
|
||||
_calReadableTextColor,
|
||||
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
||||
} from './calendar/utils.js';
|
||||
@@ -413,8 +414,8 @@ function _calEventFg(ev) {
|
||||
// Returns '' for normal solid-color events.
|
||||
function _calItemBgStyle(ev) {
|
||||
if (!_isCalBgImage(ev.color)) return '';
|
||||
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`;
|
||||
const url = _calBgImageUrl(ev.color);
|
||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`;
|
||||
}
|
||||
|
||||
function _todayCount() {
|
||||
@@ -470,7 +471,7 @@ function _showEventMoreMenu(ev, anchor) {
|
||||
dropdown.className = 'cal-event-dropdown';
|
||||
let closeMenu = () => dropdown.remove();
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
|
||||
|
||||
const _item = (icon, label, onClick, danger) => {
|
||||
const it = document.createElement('div');
|
||||
@@ -1260,8 +1261,8 @@ async function _renderWeek() {
|
||||
// events keep the original tinted treatment.
|
||||
let bgDecl;
|
||||
if (_isCalBgImage(ev.color)) {
|
||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`;
|
||||
const _url = _calBgImageUrl(ev.color);
|
||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`;
|
||||
} else {
|
||||
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
||||
}
|
||||
@@ -2853,7 +2854,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
let bg;
|
||||
if (isCustom) {
|
||||
const url = _calBgImageUrl(cur);
|
||||
bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT;
|
||||
bg = url ? `center/cover no-repeat url('${_cssUrlEscape(url)}')` : _CAL_CUSTOM_GRADIENT;
|
||||
} else {
|
||||
bg = c.hex || 'var(--border)';
|
||||
}
|
||||
@@ -2928,7 +2929,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
// stays readable. Chrome accent falls back to the theme accent.
|
||||
const url = _calBgImageUrl(hex);
|
||||
_formCard.style.setProperty('--ev-color', 'var(--accent)');
|
||||
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`;
|
||||
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${_cssUrlEscape(url)}')`;
|
||||
_formCard.style.backgroundSize = 'cover';
|
||||
_formCard.style.backgroundPosition = 'center';
|
||||
_formCard.classList.add('cal-form-bg-image');
|
||||
@@ -2950,7 +2951,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
if (!url) return;
|
||||
const sentinel = 'bg:' + url;
|
||||
dot.dataset.color = sentinel;
|
||||
dot.style.background = `center/cover no-repeat url('${url}')`;
|
||||
dot.style.background = `center/cover no-repeat url('${_cssUrlEscape(url)}')`;
|
||||
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
|
||||
dot.classList.add('active');
|
||||
_applyFormTint(sentinel);
|
||||
|
||||
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
|
||||
return _isCalBgImage(c) ? c.slice(3) : '';
|
||||
}
|
||||
|
||||
// Escape a value for safe embedding inside a single-quoted CSS `url('...')`.
|
||||
// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the
|
||||
// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we
|
||||
// add for `'` and let the value break out of the string (CodeQL
|
||||
// js/incomplete-sanitization). `"` is percent-encoded for good measure.
|
||||
export function _cssUrlEscape(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '%22');
|
||||
}
|
||||
|
||||
// Returns a value safe to drop into `style="background:..."`. Falls back to
|
||||
// the calendar default for bg-image events in spots where an image would be
|
||||
// too small to render usefully (small grid dots, multi-day bars).
|
||||
export function _calBgCss(c, fallback) {
|
||||
if (_isCalBgImage(c)) {
|
||||
const u = _calBgImageUrl(c);
|
||||
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)');
|
||||
return u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)');
|
||||
}
|
||||
return c || fallback || 'var(--accent)';
|
||||
}
|
||||
|
||||
+13
-142
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
|
||||
import chatStream from './chatStream.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import presetsModule from './presets.js';
|
||||
import fileHandlerModule from './fileHandler.js';
|
||||
@@ -2321,148 +2320,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
} else if (json.type === 'ask_user') {
|
||||
if (_isBg) continue;
|
||||
// The agent posed a multiple-choice question; the turn has ended.
|
||||
// Render clickable options at the bottom of the history. The
|
||||
// user's pick is sent as the next message and the agent resumes.
|
||||
// Use the shared history renderer so the live and restored
|
||||
// versions have identical behavior.
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
const _aq = json.data || {};
|
||||
const _opts = Array.isArray(_aq.options) ? _aq.options : [];
|
||||
if (_aq.question && _opts.length) {
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
// Drop any prior unanswered card so only the latest shows.
|
||||
chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ask-user-card';
|
||||
const multi = !!_aq.multi;
|
||||
// Group the choices for assistive tech and label the group with
|
||||
// the question (set below); make the card focusable so it can be
|
||||
// moved to when it appears.
|
||||
card.setAttribute('role', 'group');
|
||||
card.tabIndex = -1;
|
||||
// Render any emoji in agent-supplied text through the app's
|
||||
// pipeline: escape, then svgify to monochrome theme-tinted
|
||||
// glyphs (project rule: never colorful emoji; respects the
|
||||
// "Text-only Emojis" setting like the rest of the chat).
|
||||
const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
|
||||
|
||||
// Header row holds the close (×) to dismiss the affordances and
|
||||
// just type a reply instead.
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ask-user-head';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const mi = uiModule.el('message');
|
||||
if (mi) mi.focus();
|
||||
});
|
||||
head.appendChild(closeBtn);
|
||||
card.appendChild(head);
|
||||
|
||||
// Render the question inside the card so it's self-contained:
|
||||
// some models call ask_user without first narrating the question
|
||||
// as assistant text, in which case the card would otherwise show
|
||||
// bare options with no prompt.
|
||||
if (_aq.question) {
|
||||
const q = document.createElement('div');
|
||||
q.className = 'ask-user-question';
|
||||
q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
q.innerHTML = _emo(_aq.question);
|
||||
card.appendChild(q);
|
||||
// Label the choice group with the question for screen readers.
|
||||
card.setAttribute('aria-labelledby', q.id);
|
||||
} else {
|
||||
card.setAttribute('aria-label', 'Question from the assistant');
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ask-user-options';
|
||||
card.appendChild(list);
|
||||
|
||||
const _send = (text) => {
|
||||
if (!text) return;
|
||||
// Remove the card once answered — the choice is sent as a
|
||||
// normal user message (and the question persists as the
|
||||
// assistant text above), so the affordances are spent.
|
||||
card.remove();
|
||||
const mi = uiModule.el('message');
|
||||
if (mi) mi.value = text;
|
||||
const sb = document.querySelector('.send-btn');
|
||||
if (sb) sb.click();
|
||||
};
|
||||
|
||||
_opts.forEach((opt, i) => {
|
||||
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||
if (!label) return;
|
||||
const descr = (opt && opt.description) ? String(opt.description) : '';
|
||||
const row = document.createElement(multi ? 'label' : 'button');
|
||||
row.className = 'ask-user-option';
|
||||
if (multi) {
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = label;
|
||||
row.appendChild(cb);
|
||||
}
|
||||
const txt = document.createElement('span');
|
||||
txt.className = 'ask-user-option-label';
|
||||
txt.innerHTML = _emo(label);
|
||||
row.appendChild(txt);
|
||||
if (descr) {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'ask-user-option-desc';
|
||||
d.innerHTML = _emo(descr);
|
||||
row.appendChild(d);
|
||||
}
|
||||
if (!multi) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => _send(label));
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
// Free-text "Other" — type a custom answer + send (Enter or →).
|
||||
const other = document.createElement('div');
|
||||
other.className = 'ask-user-other';
|
||||
const otherInput = document.createElement('input');
|
||||
otherInput.type = 'text';
|
||||
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||
const otherSend = document.createElement('button');
|
||||
otherSend.type = 'button';
|
||||
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||
otherSend.setAttribute('aria-label', 'Send answer');
|
||||
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||
const _submit = () => {
|
||||
const free = otherInput.value.trim();
|
||||
if (multi) {
|
||||
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
|
||||
if (free) picked.push(free);
|
||||
if (picked.length) _send(picked.join(', '));
|
||||
} else if (free) {
|
||||
_send(free);
|
||||
}
|
||||
};
|
||||
otherSend.addEventListener('click', _submit);
|
||||
otherInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
_submit();
|
||||
}
|
||||
});
|
||||
other.appendChild(otherInput);
|
||||
other.appendChild(otherSend);
|
||||
card.appendChild(other);
|
||||
|
||||
chatBox.appendChild(card);
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
// Move focus to the card so keyboard/screen-reader users land on
|
||||
// the question + choices when it appears.
|
||||
try { card.focus(); } catch (_) {}
|
||||
}
|
||||
chatRenderer.renderAskUserCard(json.data || {});
|
||||
|
||||
} else if (json.type === 'plan_update') {
|
||||
if (_isBg) continue;
|
||||
@@ -5019,7 +4881,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!header) return;
|
||||
const node = header.closest('.agent-thread-node');
|
||||
if (!node) return;
|
||||
node.classList.toggle('open');
|
||||
const opened = node.classList.toggle('open');
|
||||
if (opened) {
|
||||
// Expanding the final tool trace can push a pending ask_user card below
|
||||
// the viewport. Keep that immediately-adjacent prompt visible.
|
||||
const thread = node.closest('.agent-thread');
|
||||
const pendingCard = thread?.nextElementSibling;
|
||||
if (pendingCard?.classList.contains('ask-user-card')) {
|
||||
requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
|
||||
}
|
||||
}
|
||||
});
|
||||
window.__odysseus_thread_click_bound = true;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import { providerLogo, providerLabel } from './providers.js';
|
||||
import settingsModule from './settings.js';
|
||||
@@ -1974,6 +1975,142 @@ export function displayMetrics(messageElement, metrics) {
|
||||
if (uiModule) uiModule.scrollHistory();
|
||||
}
|
||||
|
||||
/** Remove any unanswered multiple-choice cards currently in the chat. */
|
||||
export function removeAskUserCards(root) {
|
||||
const scope = root || document.getElementById('chat-history') || document;
|
||||
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an ask_user payload as a durable choice card.
|
||||
*
|
||||
* This lives in the history renderer rather than the streaming loop so the
|
||||
* same UI can be used both for a live SSE event and for a persisted tool event
|
||||
* after a session reload.
|
||||
*/
|
||||
export function renderAskUserCard(payload, options) {
|
||||
const aq = payload || {};
|
||||
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||
|
||||
const renderOptions = options || {};
|
||||
removeAskUserCards(chatBox);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ask-user-card';
|
||||
card.setAttribute('role', 'group');
|
||||
card.tabIndex = -1;
|
||||
const multi = !!aq.multi;
|
||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ask-user-head';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.focus();
|
||||
});
|
||||
head.appendChild(closeBtn);
|
||||
card.appendChild(head);
|
||||
|
||||
const question = document.createElement('div');
|
||||
question.className = 'ask-user-question';
|
||||
question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
question.innerHTML = emojiText(aq.question);
|
||||
card.appendChild(question);
|
||||
card.setAttribute('aria-labelledby', question.id);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ask-user-options';
|
||||
card.appendChild(list);
|
||||
|
||||
const send = (text) => {
|
||||
if (!text) return;
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.value = text;
|
||||
const sendButton = document.querySelector('.send-btn');
|
||||
if (sendButton) sendButton.click();
|
||||
};
|
||||
|
||||
opts.forEach((opt) => {
|
||||
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||
if (!label) return;
|
||||
const description = (opt && opt.description) ? String(opt.description) : '';
|
||||
const row = document.createElement(multi ? 'label' : 'button');
|
||||
row.className = 'ask-user-option';
|
||||
if (multi) {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = label;
|
||||
row.appendChild(checkbox);
|
||||
}
|
||||
const labelText = document.createElement('span');
|
||||
labelText.className = 'ask-user-option-label';
|
||||
labelText.innerHTML = emojiText(label);
|
||||
row.appendChild(labelText);
|
||||
if (description) {
|
||||
const descriptionText = document.createElement('span');
|
||||
descriptionText.className = 'ask-user-option-desc';
|
||||
descriptionText.innerHTML = emojiText(description);
|
||||
row.appendChild(descriptionText);
|
||||
}
|
||||
if (!multi) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => send(label));
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
const other = document.createElement('div');
|
||||
other.className = 'ask-user-other';
|
||||
const otherInput = document.createElement('input');
|
||||
otherInput.type = 'text';
|
||||
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||
const otherSend = document.createElement('button');
|
||||
otherSend.type = 'button';
|
||||
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||
otherSend.setAttribute('aria-label', 'Send answer');
|
||||
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||
const submit = () => {
|
||||
const freeText = otherInput.value.trim();
|
||||
if (multi) {
|
||||
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value);
|
||||
if (freeText) picked.push(freeText);
|
||||
if (picked.length) send(picked.join(', '));
|
||||
} else if (freeText) {
|
||||
send(freeText);
|
||||
}
|
||||
};
|
||||
otherSend.addEventListener('click', submit);
|
||||
otherInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
other.appendChild(otherInput);
|
||||
other.appendChild(otherSend);
|
||||
card.appendChild(other);
|
||||
|
||||
chatBox.appendChild(card);
|
||||
if (renderOptions.scroll !== false) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if (renderOptions.focus !== false) {
|
||||
try { card.focus(); } catch (_) {}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the chat history.
|
||||
*/
|
||||
@@ -1983,6 +2120,11 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) { console.error('Chat history element not found'); return; }
|
||||
|
||||
// Loading a later user message means any earlier ask_user card was
|
||||
// answered. This also removes the live card as soon as a manual reply is
|
||||
// appended, even when the user did not click one of its buttons.
|
||||
if (role === 'user') removeAskUserCards(box);
|
||||
|
||||
var esc = uiModule.esc;
|
||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||
|
||||
@@ -1990,6 +2132,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||
const roundTexts = metadata.round_texts || [];
|
||||
const toolEvents = metadata.tool_events;
|
||||
let pendingAskUser = null;
|
||||
let lastWrap = null;
|
||||
let firstMsgAi = null;
|
||||
let lastMsgAi = null;
|
||||
@@ -2066,6 +2209,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.appendChild(threadWrap);
|
||||
}
|
||||
for (const ev of roundTools) {
|
||||
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||
let outHtml = '';
|
||||
if (ev.output && ev.output.trim()) {
|
||||
@@ -2129,6 +2273,12 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
}
|
||||
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
||||
if (pendingAskUser) {
|
||||
// Session history is rendered oldest-to-newest. A later user message
|
||||
// removes this card; if there is none, the pending choice survives a
|
||||
// refresh. Avoid stealing focus while the history is loading.
|
||||
renderAskUserCard(pendingAskUser, { focus: false, scroll: false });
|
||||
}
|
||||
return lastWrap;
|
||||
}
|
||||
|
||||
@@ -2461,6 +2611,8 @@ const chatRenderer = {
|
||||
copyMessageText,
|
||||
safeToolScreenshotSrc,
|
||||
safeDisplayImageSrc,
|
||||
removeAskUserCards,
|
||||
renderAskUserCard,
|
||||
buildSourcesBox,
|
||||
buildFindingsBox,
|
||||
appendReportButton,
|
||||
|
||||
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js';
|
||||
import presetsModule from '../presets.js';
|
||||
import markdownModule from '../markdown.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
|
||||
}
|
||||
|
||||
let _exportMenuEl = null;
|
||||
let _closeExportMenu = () => {};
|
||||
function _toggleExportMenu(btn) {
|
||||
if (_exportMenuEl) { _closeExportMenu(); return; }
|
||||
const r = btn.getBoundingClientRect();
|
||||
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
|
||||
}
|
||||
document.body.appendChild(m);
|
||||
_exportMenuEl = m;
|
||||
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
|
||||
}
|
||||
function _closeExportMenu() {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
_closeExportMenu = bindMenuDismiss(m, () => {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
}, (ev) => !m.contains(ev.target));
|
||||
}
|
||||
|
||||
async function _exportCopyMarkdown(_btn) {
|
||||
|
||||
+9
-11
@@ -33,6 +33,9 @@ import {
|
||||
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
||||
} from './cookbookServe.js';
|
||||
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const STORAGE_KEY = 'cookbook-presets';
|
||||
const LAST_STATE_KEY = 'cookbook-last-state';
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
@@ -1514,7 +1517,7 @@ async function _fetchDependencies() {
|
||||
|
||||
// Wire the installed-package menu.
|
||||
function _showDepMenu(anchor) {
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||
const row = anchor.closest('.cookbook-dep-row');
|
||||
if (!row) return;
|
||||
const pipName = row.dataset.depPip;
|
||||
@@ -1527,7 +1530,7 @@ async function _fetchDependencies() {
|
||||
const minW = 150;
|
||||
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
|
||||
left = Math.max(8, left);
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact';
|
||||
@@ -1535,7 +1538,7 @@ async function _fetchDependencies() {
|
||||
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
||||
it.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
close();
|
||||
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
||||
});
|
||||
dropdown.appendChild(it);
|
||||
@@ -1563,19 +1566,14 @@ async function _fetchDependencies() {
|
||||
dropdown.appendChild(source);
|
||||
}
|
||||
document.body.appendChild(dropdown);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
|
||||
!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
|
||||
}
|
||||
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (document.querySelector('.cookbook-dep-menu')) {
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||
return;
|
||||
}
|
||||
_showDepMenu(btn);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
import { _hwfitCache } from './cookbook-hwfit.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
// Shared state/functions injected by init()
|
||||
let _envState;
|
||||
@@ -1019,7 +1020,7 @@ function _rerenderCachedModels() {
|
||||
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
||||
dropdown.appendChild(cancelDiv);
|
||||
const rect = btn.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||
document.body.appendChild(dropdown);
|
||||
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
|
||||
// on mobile under the dynamic toolbar). Flip above the button if there's no
|
||||
@@ -2166,7 +2167,7 @@ function _rerenderCachedModels() {
|
||||
// Cap width/height to the viewport and start hidden — we clamp the final
|
||||
// position after mount (below) using the menu's real measured size, so it
|
||||
// can't run off-screen on a narrow mobile viewport.
|
||||
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
|
||||
if (!modelSlots.length) {
|
||||
const empty = document.createElement('div');
|
||||
|
||||
+22
-38
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
|
||||
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
|
||||
import signatureModule from './signature.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
let API_BASE = '';
|
||||
let isOpen = false;
|
||||
@@ -666,7 +667,7 @@ import * as Modals from './modalManager.js';
|
||||
overlay.className = 'modal pdf-export-overlay';
|
||||
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content" style="width:min(780px,94vw);max-height:86vh;">
|
||||
<div class="modal-content" style="width:min(780px,94vw);">
|
||||
<div class="modal-header">
|
||||
<h4>Export filled PDF</h4>
|
||||
<button id="pdf-export-close" class="modal-close" title="Close">×</button>
|
||||
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
|
||||
let _docAiReplyChoiceMenu = null;
|
||||
function _closeDocAiReplyChoice() {
|
||||
if (_docAiReplyChoiceMenu) {
|
||||
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
|
||||
// Tear down through the menu's registered dismiss (drops its outside-click
|
||||
// listener + Escape-stack entry) rather than orphaning them with a raw
|
||||
// remove(); the onClose below nulls the ref.
|
||||
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
|
||||
_docAiReplyChoiceMenu = null;
|
||||
}
|
||||
}
|
||||
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
|
||||
const noteInput = menu.querySelector('[data-note-input]');
|
||||
setTimeout(() => noteInput?.focus(), 0);
|
||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
// Outside-click AND Escape both route through the central esc-stack via
|
||||
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
|
||||
const close = bindMenuDismiss(menu, () => {
|
||||
try { menu.remove(); } catch (_) {}
|
||||
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
|
||||
});
|
||||
menu.addEventListener('click', async (ev) => {
|
||||
const choice = ev.target.closest('[data-mode]');
|
||||
if (!choice) return;
|
||||
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
|
||||
ev.stopPropagation();
|
||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||
const noteHint = (noteInput?.value || '').trim();
|
||||
_closeDocAiReplyChoice();
|
||||
close();
|
||||
await _aiReply({ mode, noteHint });
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
const outsideClose = (ev) => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
document.removeEventListener('click', outsideClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
||||
// Esc to close.
|
||||
const escClose = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.stopPropagation();
|
||||
document.removeEventListener('keydown', escClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escClose, true);
|
||||
}
|
||||
|
||||
async function _aiReply(opts = {}) {
|
||||
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
function showExportMenu(e, anchorRect) {
|
||||
if (e) e.stopPropagation();
|
||||
// Remove existing menu if any
|
||||
// Remove existing menu if any (toggle off) — tear it down through its
|
||||
// registered dismiss so the outside-click listener + Escape-stack entry go.
|
||||
const existing = document.getElementById('doc-export-menu');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { dismissOrRemove(existing); return; }
|
||||
|
||||
// Position from provided rect, clicked element, or fallback to language select
|
||||
const rect = anchorRect
|
||||
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
|
||||
const item = document.createElement('button');
|
||||
item.className = 'doc-overflow-item';
|
||||
item.textContent = opt.label;
|
||||
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); });
|
||||
item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
|
||||
menu.appendChild(item);
|
||||
if (opt._divider) {
|
||||
const sep = document.createElement('div');
|
||||
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
|
||||
menu.style.top = 'auto';
|
||||
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
||||
}
|
||||
const close = (ev) => {
|
||||
if (ev && ev.type === 'keydown') {
|
||||
if (ev.key !== 'Escape') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.stopImmediatePropagation?.();
|
||||
} else if (ev && menu.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close);
|
||||
document.removeEventListener('keydown', close, true);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 100);
|
||||
document.addEventListener('keydown', close, true);
|
||||
// Outside-click AND Escape both route through the central esc-stack via
|
||||
// bindMenuDismiss; onClose owns the actual node removal.
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
}
|
||||
|
||||
function exportAsHtml() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Extracted from document.js to reduce file size.
|
||||
*/
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import uiModule from './ui.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
@@ -227,7 +228,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
dd.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
dd.style.top = (rect.bottom + 2) + 'px';
|
||||
dd.style.display = 'block';
|
||||
dd.style.zIndex = '100000';
|
||||
dd.style.zIndex = String(topPortalZ());
|
||||
requestAnimationFrame(() => {
|
||||
const mr = dd.getBoundingClientRect();
|
||||
if (mr.bottom > window.innerHeight - 8) {
|
||||
@@ -629,7 +630,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
document.body.appendChild(dropdown);
|
||||
dropdown.dataset.owner = doc.id;
|
||||
dropdown.style.cssText = 'position:fixed;z-index:10000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;';
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;display:block;`;
|
||||
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||
dropdown.style.left = 'auto';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
@@ -1595,7 +1596,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
modal.className = 'modal';
|
||||
modal.id = 'doclib-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
|
||||
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
|
||||
Documents / Research / Archive) so the user sees ONE icon at
|
||||
|
||||
+6
-11
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
const _acct = () => window.__odysseusActiveEmailAccount
|
||||
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
}
|
||||
|
||||
function _showEmailMenu(em, anchor, itemEl) {
|
||||
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'dropdown email-dropdown show';
|
||||
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
||||
_showRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(menuItem);
|
||||
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
||||
|
||||
anchor.appendChild(dropdown);
|
||||
|
||||
const close = (e) => {
|
||||
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
|
||||
}
|
||||
|
||||
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
|
||||
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
||||
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
dismissOrRemove(parentDropdown);
|
||||
await _createReplyReminder(em, p.date);
|
||||
});
|
||||
parentDropdown.appendChild(item);
|
||||
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
||||
customItem.innerHTML = '<span>Pick date and time…</span>';
|
||||
customItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
dismissOrRemove(parentDropdown);
|
||||
const tmp = document.createElement('input');
|
||||
tmp.type = 'datetime-local';
|
||||
const def = new Date(tomorrow);
|
||||
|
||||
+32
-50
@@ -8,6 +8,7 @@ import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js';
|
||||
import settingsModule from './settings.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import {
|
||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from './emailLibrary/signatureFold.js';
|
||||
import { state } from './emailLibrary/state.js';
|
||||
import { collapseSidebarToRail } from './modalSnap.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
@@ -858,7 +860,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
modal.className = 'modal';
|
||||
modal.id = 'email-lib-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);max-height:85vh;background:var(--bg);">
|
||||
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
|
||||
@@ -4866,7 +4868,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
modal.className = 'modal email-reader-tab-modal';
|
||||
modal.id = modalId;
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);max-height:85vh;display:flex;flex-direction:column;">
|
||||
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
|
||||
<div class="modal-header">
|
||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
|
||||
@@ -5101,7 +5103,7 @@ async function _openEmailWindow(em, folder) {
|
||||
modal.id = winId;
|
||||
modal.style.cssText = 'pointer-events:none;background:transparent;';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content email-window-content" style="width:min(640px, 92vw);max-height:80vh;display:flex;flex-direction:column;background:var(--bg);">
|
||||
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
@@ -5499,23 +5501,19 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
||||
const existing = document.querySelector('.email-card-dropdown');
|
||||
if (existing && existing._anchor === anchor) {
|
||||
existing.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
dismissOrRemove(existing);
|
||||
return;
|
||||
}
|
||||
// Otherwise close any other open dropdown (and clear its anchor's active
|
||||
// state) before opening a fresh one.
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => {
|
||||
if (d._anchor) d._anchor.classList.remove('reader-more-active');
|
||||
d.remove();
|
||||
});
|
||||
// Otherwise close any other open dropdown (its own teardown clears its
|
||||
// anchor's active state) before opening a fresh one.
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
dropdown._anchor = anchor;
|
||||
anchor.classList.add('reader-more-active');
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
@@ -5721,8 +5719,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -5735,30 +5732,25 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
function _showCardMenu(em, anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
|
||||
@@ -5918,8 +5910,7 @@ function _showCardMenu(em, anchor) {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -5932,30 +5923,25 @@ function _showCardMenu(em, anchor) {
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
||||
function _showBulkActionsMenu(anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
@@ -5968,7 +5954,7 @@ function _showBulkActionsMenu(anchor) {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||
@@ -5978,7 +5964,7 @@ function _showBulkActionsMenu(anchor) {
|
||||
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
|
||||
cancelIt.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
close();
|
||||
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
||||
// documents bulk dropdown.
|
||||
state._selectMode = false;
|
||||
@@ -5989,13 +5975,9 @@ function _showBulkActionsMenu(anchor) {
|
||||
dropdown.appendChild(cancelIt);
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
function _updateBulkBar() {
|
||||
@@ -6240,7 +6222,7 @@ function _showAiReplyChoice(btn, em, data) {
|
||||
`max-height:${window.innerHeight - 16}px`,
|
||||
'overflow:auto',
|
||||
'box-sizing:border-box',
|
||||
'z-index:10060',
|
||||
`z-index:${topPortalZ()}`,
|
||||
'display:flex',
|
||||
'gap:6px',
|
||||
'padding:6px',
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
|
||||
*/
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
// Each entry: [char, label, svgPath OR svg]
|
||||
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
|
||||
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
|
||||
@@ -158,7 +160,7 @@ function togglePicker(anchor, target) {
|
||||
_pickerEl.style.position = 'fixed';
|
||||
_pickerEl.style.top = (rect.bottom + 4) + 'px';
|
||||
_pickerEl.style.left = rect.left + 'px';
|
||||
_pickerEl.style.zIndex = '10000';
|
||||
_pickerEl.style.zIndex = String(topPortalZ());
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const pr = _pickerEl.getBoundingClientRect();
|
||||
|
||||
+7
-11
@@ -6,6 +6,8 @@ import uiModule from './ui.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -2514,7 +2516,7 @@ export function openGallery() {
|
||||
// shares the exact same dropdown style/behaviour.
|
||||
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
|
||||
function _showGalleryBulkMenu(anchor) {
|
||||
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
|
||||
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
|
||||
// matches every other menu in the app. Positioned fixed at the button.
|
||||
const dropdown = document.createElement('div');
|
||||
@@ -2523,7 +2525,7 @@ export function openGallery() {
|
||||
const left = Math.min(rect.left, window.innerWidth - 200);
|
||||
// Inline the standard dropdown look so it renders correctly even where the
|
||||
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
|
||||
const _tagIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
|
||||
const _dlIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
@@ -2548,17 +2550,11 @@ export function openGallery() {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
document.body.appendChild(dropdown);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
_bulkActionsBtn?.addEventListener('click', (e) => {
|
||||
@@ -2567,7 +2563,7 @@ export function openGallery() {
|
||||
// should close it. The outside-click handler explicitly skips clicks on
|
||||
// the anchor, so the button itself has to do its own dismiss.
|
||||
const existing = document.querySelector('.gallery-bulk-menu');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { dismissOrRemove(existing); return; }
|
||||
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
|
||||
_showGalleryBulkMenu(e.currentTarget);
|
||||
});
|
||||
|
||||
+22
-6
@@ -483,6 +483,7 @@ export function processWithThinking(text) {
|
||||
export function mdToHtml(src, opts) {
|
||||
const allowedHtmlBlocks = [];
|
||||
const codeBlocks = [];
|
||||
const inlineCodeBlocks = [];
|
||||
const mermaidBlocks = [];
|
||||
let s = (src ?? '');
|
||||
|
||||
@@ -521,6 +522,19 @@ export function mdToHtml(src, opts) {
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Extract inline code spans before the link/autolink/HTML passes, mirroring
|
||||
// the fenced-block handling above. A URL inside `inline code` (e.g.
|
||||
// `irm http://127.0.0.1:3000/x`) is preceded by a space, so the bare-URL
|
||||
// autolink matches it, wraps it in an <a> tag, and swaps that for an
|
||||
// ___ALLOWED_HTML_ placeholder — corrupting the command. The old inline-code
|
||||
// pass ran after those passes, too late to protect it.
|
||||
s = s.replace(/`([^`]+?)`/g, (match, code) => {
|
||||
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___MERMAID_BLOCK_')) return match;
|
||||
const placeholder = `___INLINE_CODE_${inlineCodeBlocks.length}___`;
|
||||
inlineCodeBlocks.push(`<code>${escapeHtml(code)}</code>`);
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Repair common ways the agent mangles the entity-anchor convention
|
||||
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
|
||||
// right but slip into other formats when listing many in a table.
|
||||
@@ -678,12 +692,6 @@ export function mdToHtml(src, opts) {
|
||||
return html;
|
||||
});
|
||||
|
||||
// Inline code (but not placeholders)
|
||||
s = s.replace(/`([^`]+?)`/g, (match, code) => {
|
||||
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match;
|
||||
return `<code>${code}</code>`;
|
||||
});
|
||||
|
||||
// Horizontal rules (must come before bold/italic to avoid * conflicts)
|
||||
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
|
||||
|
||||
@@ -756,6 +764,14 @@ export function mdToHtml(src, opts) {
|
||||
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
||||
});
|
||||
|
||||
// Restore inline code spans last, so placeholders carried inside restored
|
||||
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
|
||||
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
|
||||
// as a regex back-reference.
|
||||
inlineCodeBlocks.forEach((block, index) => {
|
||||
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
||||
});
|
||||
|
||||
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -865,7 +866,13 @@ export function renderMemoryList() {
|
||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
dropdown.style.left = 'auto';
|
||||
dropdown.style.zIndex = '10001';
|
||||
// Portaled to <body>, so it must outrank the Brain modal it belongs to.
|
||||
// Tool modals get a monotonically increasing z-index from modalManager's
|
||||
// bring-to-front counter, which climbs unbounded over a long session —
|
||||
// once it passed the old hardcoded 10001 the menu rendered behind the
|
||||
// panel (#4720). topPortalZ() derives the value from the live tool-window
|
||||
// stack so the menu always sits just above, however high it has climbed.
|
||||
dropdown.style.zIndex = String(topPortalZ());
|
||||
dropdown.style.display = 'block';
|
||||
document.body.appendChild(dropdown);
|
||||
// Keep on-screen (mobile): flip above the button if it overflows the
|
||||
|
||||
+11
-18
@@ -10,7 +10,8 @@ import { attachColorPicker } from './colorPicker.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||
import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
|
||||
|
||||
function _pickCustomDate() {
|
||||
// Replace the dropdown menu with a small inline picker
|
||||
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
|
||||
document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'note-reminder-menu';
|
||||
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
|
||||
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
|
||||
if (typeof dInput.showPicker === 'function') {
|
||||
try { dInput.showPicker(); } catch {}
|
||||
}
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
|
||||
if (dInput.value) _setReminder(dInput.value);
|
||||
menu.remove();
|
||||
close();
|
||||
});
|
||||
setTimeout(() => {
|
||||
const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
|
||||
document.addEventListener('click', close);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
|
||||
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
|
||||
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
|
||||
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
|
||||
function _openNoteCornerMenu(btn) {
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
|
||||
const id = btn.dataset.noteId;
|
||||
const note = _notes.find(n => n.id === id);
|
||||
if (!note) return;
|
||||
@@ -4337,15 +4335,10 @@ function _openNoteCornerMenu(btn) {
|
||||
const mh = menu.offsetHeight || 96;
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||
const close = (ev) => {
|
||||
if (ev && menu.contains(ev.target)) return;
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
|
||||
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||
}
|
||||
|
||||
function _positionNoteMenu(menu, btn, width = 196) {
|
||||
@@ -4356,7 +4349,7 @@ function _positionNoteMenu(menu, btn, width = 196) {
|
||||
const mh = menu.offsetHeight || 112;
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||
const close = (ev) => {
|
||||
if (ev && menu.contains(ev.target)) return;
|
||||
menu.remove();
|
||||
|
||||
@@ -1938,6 +1938,7 @@ async function _onSessionListKeydown(e) {
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (item.querySelector('.session-rename-input')) return;
|
||||
e.preventDefault();
|
||||
const sid = item.dataset.sessionId;
|
||||
const s = sessions.find(x => x.id === sid);
|
||||
|
||||
+16
-9
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
|
||||
if (lbl) lbl.textContent = text;
|
||||
if (ico) ico.innerHTML = _apiIconFor(k);
|
||||
};
|
||||
const _close = () => { menu.style.display = 'none'; };
|
||||
// Menu is reused (hidden, not recreated). close() hides it and tears down
|
||||
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||
// re-registered fresh on each open (see _open).
|
||||
let _close = () => { menu.style.display = 'none'; };
|
||||
const _open = () => {
|
||||
menu.style.display = 'block';
|
||||
const tRect = trig.getBoundingClientRect();
|
||||
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
|
||||
const above = tRect.top;
|
||||
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
|
||||
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
|
||||
};
|
||||
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
||||
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
||||
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
|
||||
if (labelEl) labelEl.textContent = lbl;
|
||||
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
|
||||
};
|
||||
const _closeMenu = () => { menu.style.display = 'none'; };
|
||||
// Menu is reused (hidden, not recreated). _closeMenu hides it and tears
|
||||
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||
// re-registered fresh on each open (see _openMenu).
|
||||
let _closeMenu = () => { menu.style.display = 'none'; };
|
||||
const _openMenu = () => {
|
||||
menu.style.display = 'block';
|
||||
// Drop-up when there's not enough room below the trigger.
|
||||
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
|
||||
} else {
|
||||
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
||||
}
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
|
||||
};
|
||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
||||
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
||||
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
|
||||
addBtn.parentElement.style.position = 'relative';
|
||||
addBtn.parentElement.classList.add('uf-add-anchor');
|
||||
}
|
||||
// Menu is created per open and removed on close. _closeMenu routes through
|
||||
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
|
||||
// listener + Escape-stack entry are torn down alongside the node removal.
|
||||
let _menuEl = null;
|
||||
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
|
||||
let _closeMenu = () => {};
|
||||
addBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_menuEl) { _closeMenu(); return; }
|
||||
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
|
||||
showForm(k, 'new');
|
||||
});
|
||||
});
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -7,6 +7,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API = window.location.origin;
|
||||
let skills = [];
|
||||
@@ -391,14 +392,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
|
||||
// Kebab dropdown for a collapsed skill card — same actions + icons as the
|
||||
// expanded footer (Publish/Unpublish · Edit · Delete).
|
||||
function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
|
||||
document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'skill-kebab-menu';
|
||||
const mk = (paths, label, opts, onClick) => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
|
||||
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
|
||||
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
|
||||
item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
|
||||
menu.appendChild(item);
|
||||
};
|
||||
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
|
||||
@@ -410,7 +411,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
|
||||
selItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.remove();
|
||||
close();
|
||||
if (!_selectMode) _enterSelectMode();
|
||||
_selectedNames.add(name);
|
||||
renderSkillsList();
|
||||
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
const cancelItem = document.createElement('button');
|
||||
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
|
||||
cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
|
||||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
|
||||
menu.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
@@ -453,8 +454,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
|
||||
menu.style.overflowY = 'auto';
|
||||
}
|
||||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
|
||||
}
|
||||
|
||||
// Cards for the agent's built-in tool capabilities (from
|
||||
@@ -1802,7 +1802,7 @@ async function _showSkillSource(name) {
|
||||
wrap.className = 'modal';
|
||||
wrap.style.display = 'block';
|
||||
wrap.innerHTML = `
|
||||
<div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column">
|
||||
<div class="modal-content" style="max-width:760px;display:flex;flex-direction:column">
|
||||
<div class="modal-header">
|
||||
<h4>SKILL.md — <code>${esc(name)}</code></h4>
|
||||
<span style="flex:1"></span>
|
||||
|
||||
@@ -101,6 +101,8 @@ function _setupProviderFromInput(input) {
|
||||
xai: 'xai',
|
||||
grok: 'xai',
|
||||
nvidia: 'nvidia',
|
||||
opencodezen: 'opencode-zen',
|
||||
opencodego: 'opencode-go',
|
||||
};
|
||||
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
|
||||
}
|
||||
@@ -129,6 +131,8 @@ function _extractSetupProviderCredential(input) {
|
||||
['google', 'gemini'], ['gemini', 'gemini'],
|
||||
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
|
||||
['nvidia', 'nvidia'],
|
||||
['opencode zen', 'opencode-zen'], ['opencode-zen', 'opencode-zen'],
|
||||
['opencode go', 'opencode-go'], ['opencode-go', 'opencode-go'],
|
||||
];
|
||||
for (const [alias, key] of providerAliases) {
|
||||
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
|
||||
|
||||
@@ -24,6 +24,7 @@ export const KEYS = {
|
||||
SECTION_ORDER: 'sidebar-section-order',
|
||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||
DENSITY: 'odysseus-density',
|
||||
UI_SCALE: 'odysseus-ui-scale',
|
||||
WORKSPACE: 'odysseus-workspace'
|
||||
};
|
||||
|
||||
|
||||
+8
-10
@@ -8,6 +8,7 @@ import * as spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { ordinalSuffix } from './util/ordinal.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) {
|
||||
|
||||
function _showTaskDropdown(anchor, items) {
|
||||
// Remove any existing dropdown
|
||||
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'task-dropdown';
|
||||
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
||||
@@ -914,7 +915,7 @@ function _showTaskDropdown(anchor, items) {
|
||||
}
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
|
||||
dd.appendChild(btn);
|
||||
});
|
||||
document.body.appendChild(dd);
|
||||
@@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) {
|
||||
dd.style.top = top + 'px';
|
||||
dd.style.left = left + 'px';
|
||||
const openedAt = performance.now();
|
||||
const close = (e) => {
|
||||
const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
|
||||
// Ignore any clicks that occur within 250ms of the open (covers touch
|
||||
// "ghost click" duplicates that were firing right after pointerup and
|
||||
// removing the dropdown before the user could see it).
|
||||
if (performance.now() - openedAt < 250) return;
|
||||
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
|
||||
};
|
||||
// requestAnimationFrame so the listener is registered AFTER the current
|
||||
// pointer/click event cycle has finished bubbling.
|
||||
requestAnimationFrame(() => document.addEventListener('click', close));
|
||||
// removing the dropdown before the user could see it) — treat as inside.
|
||||
if (performance.now() - openedAt < 250) return false;
|
||||
return !dd.contains(ev.target);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Presets ----
|
||||
|
||||
@@ -39,6 +39,7 @@ const FONT_MAP = {
|
||||
mono: "'Fira Code', monospace",
|
||||
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
opendyslexic: "'OpenDyslexic', sans-serif",
|
||||
};
|
||||
const DEFAULT_FONT = 'mono';
|
||||
const DEFAULT_DENSITY = 'comfortable';
|
||||
@@ -387,6 +388,20 @@ export function applyFontDensity(font, density) {
|
||||
if (d !== 'comfortable') document.documentElement.classList.add('density-' + d);
|
||||
}
|
||||
|
||||
// UI text-size scale (accessibility). Global and independent of the active
|
||||
// theme, so the chosen size persists across theme switches. Stored as a plain
|
||||
// percentage string ('100' | '110' | '125' | '150').
|
||||
const UI_SCALE_KEY = 'odysseus-ui-scale';
|
||||
const DEFAULT_UI_SCALE = '100';
|
||||
|
||||
export function applyUiScale(scale) {
|
||||
const s = scale || DEFAULT_UI_SCALE;
|
||||
// Only one non-default scale ('125'). Remove any legacy classes too so an
|
||||
// older stored value can't leave a stale zoom applied.
|
||||
document.documentElement.classList.remove('ui-scale-110', 'ui-scale-125', 'ui-scale-140');
|
||||
if (s === '125') document.documentElement.classList.add('ui-scale-125');
|
||||
}
|
||||
|
||||
const _BG_CLASSES = ['bg-pattern-dots',
|
||||
'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations',
|
||||
'bg-pattern-perlin-flow',
|
||||
@@ -1133,6 +1148,18 @@ export function initThemeUI() {
|
||||
const s = getSaved(); if (s) _saveFull(s.name, s.colors);
|
||||
});
|
||||
}
|
||||
const textSizeSelect = document.getElementById('theme-text-size-select');
|
||||
if (textSizeSelect) {
|
||||
const nts = textSizeSelect.cloneNode(true); textSizeSelect.parentNode.replaceChild(nts, textSizeSelect);
|
||||
let initScale = DEFAULT_UI_SCALE;
|
||||
try { initScale = localStorage.getItem(UI_SCALE_KEY) || DEFAULT_UI_SCALE; } catch (e) {}
|
||||
nts.value = initScale;
|
||||
applyUiScale(initScale);
|
||||
nts.addEventListener('change', () => {
|
||||
applyUiScale(nts.value);
|
||||
try { localStorage.setItem(UI_SCALE_KEY, nts.value); } catch (e) {}
|
||||
});
|
||||
}
|
||||
if (patternSelect) {
|
||||
const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect);
|
||||
np.value = _initPattern;
|
||||
|
||||
@@ -27,3 +27,20 @@ export function nextToolWindowZ(options = {}) {
|
||||
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
|
||||
return top + 1;
|
||||
}
|
||||
|
||||
// Dock chips pinned by the minimized-dock drag interactions reach z 10030
|
||||
// (free-drag) / 10020 (mobile rest) — see modalManager.js. A body-portaled
|
||||
// dropdown has to clear those too, not just the open tool-window stack, so this
|
||||
// floor keeps it above a chip even when no modal is currently raised.
|
||||
const DOCK_OVERLAY_FLOOR = 10030;
|
||||
|
||||
// The z a body-portaled dropdown/menu needs so it always sits just above every
|
||||
// open tool window (and the dock chips) right now. Tool modals get a
|
||||
// monotonically increasing z from the bring-to-front counter (modalManager),
|
||||
// which climbs unbounded over a long session — so the hardcoded `z-index: 10001`
|
||||
// these dropdowns historically used eventually rendered them BEHIND their own
|
||||
// modal (#4720). Derive the value from the live stack instead, sharing the same
|
||||
// single source of truth as nextToolWindowZ().
|
||||
export function topPortalZ(options = {}) {
|
||||
return Math.max(topToolWindowZ(options), DOCK_OVERLAY_FLOOR) + 1;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ body {
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
|
||||
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
|
||||
|
||||
/* Code block baseline */
|
||||
pre, code, .hljs {
|
||||
font-size: 0.95em;
|
||||
@@ -158,6 +162,39 @@ html {
|
||||
:root.density-spacious .list-item { padding: 8px 12px; }
|
||||
:root.density-spacious .sidebar .section { padding: 0; }
|
||||
|
||||
/* ── UI text-size scale (accessibility) ──
|
||||
Density only changes the root font-size, which can't move the many
|
||||
hard-coded px sizes. `zoom` scales the whole UI uniformly (px text
|
||||
included) while keeping layout intact, unlike `transform: scale`. */
|
||||
:root.ui-scale-125 { zoom: 1.25; }
|
||||
/* `zoom` makes the 100dvh shell render taller than the real viewport, which
|
||||
pushes the bottom-pinned sidebar account/settings row below the fold (and
|
||||
body's overflow:hidden then clips it). Shrink the shell by the same factor
|
||||
so it fits the viewport exactly. */
|
||||
:root.ui-scale-125 body { height: calc(100dvh / 1.25); }
|
||||
/* Modals/panels under the 1.25x scale: zoom renders a centred, viewport-sized
|
||||
panel ~1.25x taller, pushing its draggable header + close button off-screen
|
||||
(a catch-22 — you can't reach the control to turn the size back down). Divide
|
||||
each max-height by the same factor to keep the original on-screen footprint.
|
||||
Desktop only — the mobile `!important` full-sheet rules win on small screens
|
||||
and stay top-anchored, so their headers are already visible. */
|
||||
:root.ui-scale-125 .modal-content { max-height: calc(85dvh / 1.25); }
|
||||
:root.ui-scale-125 .cal-modal-content { max-height: calc(88dvh / 1.25); }
|
||||
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
|
||||
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
|
||||
/* Cookbook is the one modal that set its height inline (94vh), which beat the
|
||||
.modal-content compensation above and overflowed the viewport at 1.25x
|
||||
(header + close button pushed off-screen). Own its height here so the same
|
||||
zoom compensation applies. */
|
||||
#cookbook-modal .modal-content { height: 94vh; max-height: 94vh; }
|
||||
:root.ui-scale-125 #cookbook-modal .modal-content { height: calc(94dvh / 1.25); max-height: calc(94dvh / 1.25); }
|
||||
/* PDF export modal also set its height inline (86vh) at v1.0; that inline cap
|
||||
beat the .modal-content compensation above and shifted ~1vh at Default when
|
||||
removed. Own its height here so Default is byte-for-byte 86vh and the same
|
||||
1.25x compensation applies. */
|
||||
.pdf-export-overlay .modal-content { max-height: 86vh; }
|
||||
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
|
||||
|
||||
/* ── Background Patterns ── */
|
||||
|
||||
:root { --bg-effect-intensity: 1; }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Regression coverage for durable ``ask_user`` choice cards.
|
||||
|
||||
The live event must arrive after ``tool_output`` so the settled tool trace
|
||||
cannot cover/push away the card. The same payload must be persisted inside
|
||||
``tool_events`` so chat history can reconstruct it after a reload.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import src.agent_loop as agent_loop
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _collect(gen):
|
||||
async def _run():
|
||||
return [chunk async for chunk in gen]
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _events(chunks):
|
||||
events = []
|
||||
for chunk in chunks:
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
events.append(json.loads(chunk[6:]))
|
||||
return events
|
||||
|
||||
|
||||
def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
|
||||
payload = {
|
||||
"question": "¿Qué proyecto prefieres?",
|
||||
"options": [
|
||||
{"label": "Análisis de reseñas"},
|
||||
{"label": "Clasificación temática"},
|
||||
],
|
||||
"multi": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False)
|
||||
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
|
||||
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False)
|
||||
|
||||
async def fake_stream(_candidates, messages, **kwargs):
|
||||
call = {"name": "ask_user", "arguments": json.dumps(payload, ensure_ascii=False)}
|
||||
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
async def fake_execute(block, *args, **kwargs):
|
||||
parsed = json.loads(block.content)
|
||||
return (
|
||||
"ask_user",
|
||||
{
|
||||
"ask_user": parsed,
|
||||
"output": "Awaiting their selection.",
|
||||
"exit_code": 0,
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False)
|
||||
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False)
|
||||
|
||||
chunks = _collect(
|
||||
agent_loop.stream_agent_loop(
|
||||
"https://api.openai.com/v1",
|
||||
"gpt-4o",
|
||||
[{"role": "user", "content": "Ayúdame a elegir un proyecto."}],
|
||||
relevant_tools={"ask_user"},
|
||||
_is_teacher_run=True,
|
||||
)
|
||||
)
|
||||
events = _events(chunks)
|
||||
|
||||
tool_output_index = next(i for i, event in enumerate(events) if event.get("type") == "tool_output")
|
||||
ask_user_index = next(i for i, event in enumerate(events) if event.get("type") == "ask_user")
|
||||
assert tool_output_index < ask_user_index
|
||||
|
||||
tool_output = events[tool_output_index]
|
||||
assert tool_output["ask_user"] == payload
|
||||
assert "¿Qué proyecto prefieres?" in tool_output["command"]
|
||||
assert "\\u00" not in tool_output["command"]
|
||||
|
||||
metrics = next(event["data"] for event in events if event.get("type") == "metrics")
|
||||
assert metrics["tool_events"][0]["ask_user"] == payload
|
||||
|
||||
|
||||
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
|
||||
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
||||
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "chatRenderer.renderAskUserCard(json.data || {})" in chat
|
||||
assert "export function renderAskUserCard" in renderer
|
||||
assert "renderAskUserCard(pendingAskUser" in renderer
|
||||
assert "if (role === 'user') removeAskUserCards(box)" in renderer
|
||||
@@ -85,6 +85,19 @@ def test_serializer_round_trips_structured_args():
|
||||
assert json.loads(block.content) == args
|
||||
|
||||
|
||||
def test_serializer_keeps_unicode_readable_for_tool_trace():
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
|
||||
args = {
|
||||
"question": "¿Qué proyecto prefieres?",
|
||||
"options": [{"label": "Reseñas"}, {"label": "Clasificación"}],
|
||||
}
|
||||
block = function_call_to_tool_block("ask_user", json.dumps(args, ensure_ascii=False))
|
||||
assert "¿Qué proyecto prefieres?" in block.content
|
||||
assert "Reseñas" in block.content
|
||||
assert "\\u00" not in block.content
|
||||
|
||||
|
||||
def test_registered_everywhere():
|
||||
# TOOL_TAGS gate (serializer rejects unknown tools)
|
||||
assert "ask_user" in TOOL_TAGS
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Regression tests for auth-disabled document access (PR #4623).
|
||||
|
||||
Validates that the _auth_disabled() bypass in _verify_doc_owner and
|
||||
list_documents restores single-user / no-auth mode WITHOUT weakening the
|
||||
authenticated path. Three pinned directions:
|
||||
|
||||
1. AUTH_DISABLED + None user -> list_documents + doc read SUCCEEDS
|
||||
(the bug being fixed).
|
||||
2. AUTH_ENABLED + None user -> still 403.
|
||||
3. AUTH_ENABLED + wrong owner -> _verify_doc_owner still raises 404/403.
|
||||
|
||||
Route handlers are called directly (same pattern as
|
||||
test_document_session_owner_scope.py) so coverage lands on the real
|
||||
closures without spinning up middleware.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb
|
||||
import routes.document_routes as droutes
|
||||
from core.database import Document
|
||||
from core.database import Session as DbSession
|
||||
from routes.document_helpers import _verify_doc_owner, _owner_session_filter
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(
|
||||
f"sqlite:///{_TMPDB.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(_ENGINE)
|
||||
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
|
||||
def _req(user=None):
|
||||
"""Build a minimal fake Request whose state.current_user returns *user*."""
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user=user))
|
||||
|
||||
|
||||
def _endpoint(method, path):
|
||||
"""Resolve a route endpoint from the document router."""
|
||||
router = droutes.setup_document_routes(MagicMock(), None)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise RuntimeError(f"{method} {path} not found")
|
||||
|
||||
|
||||
def _bind_test_db():
|
||||
previous = droutes.SessionLocal
|
||||
droutes.SessionLocal = _TS
|
||||
return previous
|
||||
|
||||
|
||||
def _seed(owner="alice"):
|
||||
"""Create one session + one owned document. Returns (session_id, doc_id)."""
|
||||
session_id = f"{owner}-" + uuid.uuid4().hex[:8]
|
||||
doc_id = str(uuid.uuid4())
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(DbSession(
|
||||
id=session_id, owner=owner, name=owner,
|
||||
model="m", endpoint_url="http://x",
|
||||
))
|
||||
db.add(Document(
|
||||
id=doc_id,
|
||||
session_id=session_id,
|
||||
title=f"{owner} doc",
|
||||
language="markdown",
|
||||
current_content=f"{owner} body",
|
||||
version_count=1,
|
||||
is_active=True,
|
||||
owner=owner,
|
||||
))
|
||||
db.commit()
|
||||
return session_id, doc_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------ 1. auth DISABLED +
|
||||
# None user -> succeeds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_allows_none_user_when_auth_disabled(monkeypatch):
|
||||
"""AUTH_ENABLED=false + user=None must NOT raise 403 on list_documents."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||
session_id, doc_id = _seed()
|
||||
|
||||
# Must succeed — this is the bug fix.
|
||||
rows = await list_docs(_req(None), session_id)
|
||||
ids = [row["id"] for row in rows]
|
||||
assert doc_id in ids, "own doc must be visible in auth-disabled mode"
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_allows_none_user_when_auth_disabled(monkeypatch):
|
||||
"""AUTH_ENABLED=false + user=None must NOT raise 403 on get_document."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||
_session_id, doc_id = _seed()
|
||||
|
||||
# Must succeed — _verify_doc_owner bypasses when auth is disabled.
|
||||
result = await get_doc(_req(None), doc_id)
|
||||
assert result["id"] == doc_id
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
def test_verify_doc_owner_allows_none_user_when_auth_disabled(monkeypatch):
|
||||
"""_verify_doc_owner with user=None + AUTH_ENABLED=false must pass."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
_session_id, doc_id = _seed()
|
||||
db = _TS()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
# Must NOT raise — the bypass allows single-user access.
|
||||
_verify_doc_owner(db, doc, None)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_owner_session_filter_noops_for_none_user_when_auth_disabled(monkeypatch):
|
||||
"""_owner_session_filter with user=None + AUTH_ENABLED=false returns query unchanged."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
_session_id, doc_id = _seed()
|
||||
db = _TS()
|
||||
try:
|
||||
q = db.query(Document).filter(Document.id == doc_id)
|
||||
result = _owner_session_filter(q, None)
|
||||
# Filter was a no-op; document is still reachable.
|
||||
assert result.first().id == doc_id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------ 2. auth ENABLED +
|
||||
# None user -> 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||
"""AUTH_ENABLED=true (default) + user=None must raise 403."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||
session_id, _doc_id = _seed()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await list_docs(_req(None), session_id)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||
"""AUTH_ENABLED=true (default) + user=None must raise 403 via _verify_doc_owner."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||
_session_id, doc_id = _seed()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_doc(_req(None), doc_id)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
def test_verify_doc_owner_rejects_none_user_when_auth_enabled(monkeypatch):
|
||||
"""_verify_doc_owner with user=None + AUTH_ENABLED=true must raise 403."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
_session_id, doc_id = _seed()
|
||||
db = _TS()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_verify_doc_owner(db, doc, None)
|
||||
assert exc.value.status_code == 403
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ------------------------------------------ 3. auth ENABLED + wrong owner ->
|
||||
# _verify_doc_owner raises 404
|
||||
|
||||
|
||||
def test_verify_doc_owner_rejects_wrong_owner_when_auth_enabled(monkeypatch):
|
||||
"""_verify_doc_owner with a mismatched owner must raise 404 (not 403).
|
||||
|
||||
This confirms the authenticated path is untouched by the no-auth bypass."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
session_id, doc_id = _seed(owner="alice")
|
||||
db = _TS()
|
||||
try:
|
||||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_verify_doc_owner(db, doc, "bob") # bob != alice
|
||||
assert exc.value.status_code == 404
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_rejects_wrong_owner(monkeypatch):
|
||||
"""GET /api/document/{doc_id} with wrong authenticated user -> 404."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
get_doc = _endpoint("GET", "/api/document/{doc_id}")
|
||||
_session_id, doc_id = _seed(owner="alice")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_doc(_req("bob"), doc_id)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_documents_hides_wrong_owner_docs(monkeypatch):
|
||||
"""list_documents for alice must not show bob's documents."""
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
previous = _bind_test_db()
|
||||
try:
|
||||
list_docs = _endpoint("GET", "/api/documents/{session_id}")
|
||||
|
||||
# Seed alice's session with a doc
|
||||
alice_session, alice_doc = _seed(owner="alice")
|
||||
# Create bob's session+doc in the SAME session so ownership filter kicks in
|
||||
bob_session = "bob-" + uuid.uuid4().hex[:8]
|
||||
bob_doc = str(uuid.uuid4())
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(DbSession(id=bob_session, owner="bob", name="bob", model="m", endpoint_url="http://x"))
|
||||
db.add(Document(
|
||||
id=bob_doc, session_id=alice_session, # same session!
|
||||
title="bob doc", language="markdown", current_content="bob body",
|
||||
version_count=1, is_active=True, owner="bob",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
rows = await list_docs(_req("alice"), alice_session)
|
||||
ids = [row["id"] for row in rows]
|
||||
assert alice_doc in ids
|
||||
assert bob_doc not in ids, "wrong-owner docs must be hidden"
|
||||
finally:
|
||||
droutes.SessionLocal = previous
|
||||
@@ -0,0 +1,107 @@
|
||||
r"""DOM/CSS-injection regression for calendar background-image URL escaping.
|
||||
|
||||
CodeQL `js/incomplete-sanitization` (#463 calendar.js:416, #464 calendar.js:1263)
|
||||
flagged event-background CSS that escaped `'` -> `\'` without first escaping
|
||||
backslashes. A `bg:`-color value (settable per event, and CalDAV-syncable, so
|
||||
untrusted) ending in or containing a backslash can then consume the closing
|
||||
quote of `url('...')` and break out of the CSS string.
|
||||
|
||||
The fix is a single canonical escaper, `_cssUrlEscape`, in calendar/utils.js,
|
||||
used by both inline sinks and by `_calBgCss` (which had the same incomplete
|
||||
escaping). These tests pin the escaper: backslashes are doubled FIRST, then
|
||||
quotes, so no input can terminate the `url('...')` string early.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_UTILS = (_REPO / "static" / "js" / "calendar" / "utils.js").as_posix()
|
||||
_CALENDAR_JS = _REPO / "static" / "js" / "calendar.js"
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
|
||||
|
||||
def _run(js: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def test_cssurlescape_doubles_backslashes_before_quotes():
|
||||
js = textwrap.dedent(
|
||||
f"""
|
||||
const {{ _cssUrlEscape }} = await import('{_UTILS}');
|
||||
console.log(JSON.stringify({{
|
||||
backslash: _cssUrlEscape('a\\\\b'),
|
||||
trailing: _cssUrlEscape('img\\\\'),
|
||||
quote: _cssUrlEscape("a'b"),
|
||||
dquote: _cssUrlEscape('a"b'),
|
||||
}}));
|
||||
"""
|
||||
)
|
||||
out = json.loads(_run(js))
|
||||
# one backslash -> two; the escape for "'" is not itself re-escaped
|
||||
assert out["backslash"] == r"a\\b"
|
||||
assert out["trailing"] == "img\\\\" # 'img\' -> 'img\\'
|
||||
assert out["quote"] == r"a\'b"
|
||||
assert out["dquote"] == "a%22b"
|
||||
|
||||
|
||||
def test_backslash_breakout_payload_cannot_close_the_url_string():
|
||||
# Without the backslash-first escape, "x\" would render url('x\') and the
|
||||
# trailing backslash escapes the closing quote -> breakout. After the fix the
|
||||
# backslash is doubled, so the quote we add still terminates the string.
|
||||
js = textwrap.dedent(
|
||||
f"""
|
||||
const {{ _cssUrlEscape, _calBgCss }} = await import('{_UTILS}');
|
||||
const payload = 'x\\\\'; // a string ending in one backslash
|
||||
console.log(JSON.stringify({{
|
||||
esc: _cssUrlEscape(payload),
|
||||
css: _calBgCss('bg:' + payload, 'var(--accent)'),
|
||||
}}));
|
||||
"""
|
||||
)
|
||||
out = json.loads(_run(js))
|
||||
assert out["esc"] == "x\\\\" # doubled backslash
|
||||
# The rendered declaration keeps the backslash doubled inside url('...').
|
||||
assert "url('x\\\\')" in out["css"]
|
||||
|
||||
|
||||
def test_calbgcss_escapes_quote_breakout():
|
||||
js = textwrap.dedent(
|
||||
f"""
|
||||
const {{ _calBgCss }} = await import('{_UTILS}');
|
||||
console.log(JSON.stringify(_calBgCss("bg:a'); X{{}}//", 'var(--accent)')));
|
||||
"""
|
||||
)
|
||||
css = json.loads(_run(js))
|
||||
# the injected single quote is escaped, so the url() string is not closed early
|
||||
assert r"\'" in css
|
||||
assert "url('a\\'); X{}//')" in css
|
||||
|
||||
|
||||
def test_every_calendar_url_interpolation_is_escaped():
|
||||
# Whole-file invariant: every CSS `url('${...}')` built in calendar.js must
|
||||
# route its (CalDAV-syncable, untrusted) value through `_cssUrlEscape`. This
|
||||
# is the guard that catches a *newly added* bg-image sink the centralization
|
||||
# forgot - the failure mode that left calendar.js:2856 (edit-form color
|
||||
# swatch) and :2953 (custom-dot preview) raw before this change.
|
||||
src = _CALENDAR_JS.read_text(encoding="utf-8")
|
||||
interps = re.findall(r"url\('\$\{([^}]*)\}'\)", src)
|
||||
assert interps, "expected at least one url('${...}') interpolation in calendar.js"
|
||||
unescaped = [expr for expr in interps if "_cssUrlEscape(" not in expr]
|
||||
assert not unescaped, (
|
||||
"bg-image url() interpolation(s) not routed through _cssUrlEscape: "
|
||||
+ ", ".join(repr(e) for e in unescaped)
|
||||
)
|
||||
@@ -72,3 +72,50 @@ def test_gguf_alternate_still_recommended_on_windows():
|
||||
still appear on Windows even though the AWQ variant is hidden."""
|
||||
names = {r["name"] for r in rank_models(_windows_system(), limit=900)}
|
||||
assert "Qwen/Qwen2.5-3B-Instruct" in names
|
||||
|
||||
|
||||
def test_remote_windows_probe_uses_encoded_command(monkeypatch):
|
||||
"""Remote Windows hwfit must not use nested -Command quoting over SSH."""
|
||||
from services.hwfit import hardware
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(hardware, "_remote_host", "user@winpc")
|
||||
monkeypatch.setattr(hardware, "_remote_port", None)
|
||||
|
||||
def fake_run(cmd):
|
||||
calls.append(cmd)
|
||||
if isinstance(cmd, str) and "EncodedCommand" in cmd:
|
||||
return (
|
||||
'{"ram_gb":64,"avail_gb":32,"cpu_name":"Test CPU",'
|
||||
'"cpu_cores":8,"arch":64}'
|
||||
)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(hardware, "_run", fake_run)
|
||||
result = hardware._detect_windows()
|
||||
assert result is not None
|
||||
assert result["total_ram_gb"] == 64
|
||||
assert len(calls) == 1
|
||||
assert "EncodedCommand" in calls[0]
|
||||
assert '-Command "' not in calls[0]
|
||||
|
||||
|
||||
def test_probe_remote_platform_detects_windows(monkeypatch):
|
||||
from services.hwfit import hardware
|
||||
|
||||
monkeypatch.setattr(hardware, "_run", lambda cmd: "Windows_NT\n")
|
||||
assert hardware._probe_remote_platform() == "windows"
|
||||
|
||||
|
||||
def test_probe_remote_platform_detects_darwin(monkeypatch):
|
||||
from services.hwfit import hardware
|
||||
|
||||
def fake_run(cmd):
|
||||
if cmd == "echo %OS%":
|
||||
return "%OS%"
|
||||
if cmd == ["uname", "-s"]:
|
||||
return "Darwin"
|
||||
raise AssertionError(f"unexpected probe cmd: {cmd!r}")
|
||||
|
||||
monkeypatch.setattr(hardware, "_run", fake_run)
|
||||
assert hardware._probe_remote_platform() == "linux"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests for _normalize_mistral_content() — Mistral's structured content parser.
|
||||
|
||||
Mistral's chat completions API returns content as a typed array when reasoning
|
||||
is enabled, instead of the plain string most OpenAI-compat servers use:
|
||||
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": [{"type": "text", "text": "..."}], "closed": true},
|
||||
{"type": "text", "text": "..."}
|
||||
]
|
||||
|
||||
_normalize_mistral_content() splits that into (text, thinking) plain strings.
|
||||
The function is called from three sites:
|
||||
- llm_call (sync, non-streaming response parser)
|
||||
- llm_call_async (async, non-streaming response parser)
|
||||
- stream_llm (streaming delta parser)
|
||||
|
||||
These tests pin the contract: string passthrough, the array shape, and the
|
||||
edge cases (empty, garbage, missing fields) so a refactor doesn't silently
|
||||
drop thinking content or break non-Mistral providers.
|
||||
"""
|
||||
from src.llm_core import _normalize_mistral_content
|
||||
|
||||
|
||||
def test_string_passthrough_returns_text_with_empty_thinking():
|
||||
"""Plain string content (the common case) passes through unchanged."""
|
||||
text, thinking = _normalize_mistral_content("hello world")
|
||||
assert text == "hello world"
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_empty_string_passthrough():
|
||||
text, thinking = _normalize_mistral_content("")
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_array_with_thinking_and_text_blocks():
|
||||
"""Mistral's documented format: thinking block + text block."""
|
||||
content = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": "Let me work through this..."}],
|
||||
"closed": True,
|
||||
},
|
||||
{"type": "text", "text": "The answer is 42."},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "The answer is 42."
|
||||
assert thinking == "Let me work through this..."
|
||||
|
||||
|
||||
def test_array_with_only_thinking_block():
|
||||
"""Streaming deltas often contain only a thinking fragment (no text block yet)."""
|
||||
content = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": [{"type": "text", "text": "Okay, let's"}],
|
||||
"closed": True,
|
||||
}
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == ""
|
||||
assert thinking == "Okay, let's"
|
||||
|
||||
|
||||
def test_array_with_only_text_block():
|
||||
"""Final answer delta — only the text block, no thinking."""
|
||||
content = [{"type": "text", "text": "Final answer."}]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "Final answer."
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_array_concatenates_multiple_text_blocks():
|
||||
"""Multiple text blocks are concatenated in order."""
|
||||
content = [
|
||||
{"type": "text", "text": "part 1 "},
|
||||
{"type": "text", "text": "part 2"},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "part 1 part 2"
|
||||
|
||||
|
||||
def test_array_concatenates_multiple_thinking_fragments():
|
||||
"""Multiple thinking sub-blocks are concatenated in order."""
|
||||
content = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": [
|
||||
{"type": "text", "text": "first "},
|
||||
{"type": "text", "text": "second"},
|
||||
],
|
||||
"closed": True,
|
||||
}
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == ""
|
||||
assert thinking == "first second"
|
||||
|
||||
|
||||
def test_empty_array_returns_empty_strings():
|
||||
text, thinking = _normalize_mistral_content([])
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_array_with_garbage_entries_skips_them():
|
||||
"""Non-dict entries, missing type, missing text — all silently skipped."""
|
||||
content = [
|
||||
"not a dict",
|
||||
None,
|
||||
{"type": "unknown_type", "text": "should be ignored"},
|
||||
{"type": "text"}, # missing text key
|
||||
{"type": "thinking"}, # missing thinking key
|
||||
{"type": "text", "text": "valid text"},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "valid text"
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_none_returns_empty_strings():
|
||||
"""Defensive: None content (server bug or schema drift) doesn't crash."""
|
||||
text, thinking = _normalize_mistral_content(None)
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_int_returns_empty_strings():
|
||||
"""Defensive: wrong-typed content doesn't crash."""
|
||||
text, thinking = _normalize_mistral_content(42)
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_thinking_block_with_string_inner():
|
||||
"""Some Mistral API versions may use a string instead of an array for
|
||||
the inner 'thinking' field. Accept both shapes."""
|
||||
content = [
|
||||
{"type": "thinking", "thinking": "inline string thinking"},
|
||||
{"type": "text", "text": "answer"},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == "answer"
|
||||
assert thinking == "inline string thinking"
|
||||
|
||||
|
||||
def test_thinking_block_with_empty_text_field():
|
||||
"""Empty text fields don't pollute the output."""
|
||||
content = [
|
||||
{"type": "thinking", "thinking": [{"type": "text", "text": ""}], "closed": True},
|
||||
{"type": "text", "text": ""},
|
||||
]
|
||||
text, thinking = _normalize_mistral_content(content)
|
||||
assert text == ""
|
||||
assert thinking == ""
|
||||
@@ -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::::")
|
||||
@@ -170,6 +170,36 @@ def test_extract_thinking_blocks_handles_thought_tag(node_available):
|
||||
assert result["content"] == "Final answer."
|
||||
|
||||
|
||||
def test_url_inside_inline_code_is_not_autolinked(node_available):
|
||||
# A URL inside a backtick span is preceded by a space, so the bare-URL
|
||||
# autolink used to wrap it in an <a> tag (then swap it for an
|
||||
# ___ALLOWED_HTML_ placeholder), corrupting the command shown to the user.
|
||||
html = _run_markdown_case("Run `$j = irm http://127.0.0.1:3000/x` to fetch.")
|
||||
|
||||
assert "<code>$j = irm http://127.0.0.1:3000/x</code>" in html
|
||||
assert "___ALLOWED_HTML_" not in html
|
||||
assert "<a " not in html
|
||||
assert 'href="http://127.0.0.1:3000/x"' not in html
|
||||
|
||||
|
||||
def test_url_outside_inline_code_is_still_autolinked(node_available):
|
||||
# Inline code must not disable autolinking for bare URLs elsewhere in the
|
||||
# same line.
|
||||
html = _run_markdown_case("Use `irm` then visit https://example.com/page now.")
|
||||
|
||||
assert "<code>irm</code>" in html
|
||||
assert 'href="https://example.com/page"' in html
|
||||
|
||||
|
||||
def test_inline_code_content_is_html_escaped(node_available):
|
||||
# Inline code is now extracted before the global escape pass, so it must be
|
||||
# escaped at extraction time (matching the fenced-code-block handling).
|
||||
html = _run_markdown_case("Render `<b>$1 & 'q'</b>` literally.")
|
||||
|
||||
assert "<code><b>$1 & 'q'</b></code>" in html
|
||||
assert "<b>" not in html
|
||||
|
||||
|
||||
def test_dotted_python_import_paths_are_not_autolinked(node_available):
|
||||
html = _run_markdown_case(
|
||||
"from imblearn.combine import SMOTETomek\n"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Regression tests for Ollama-native multimodal image routing (issue #4723).
|
||||
|
||||
Odysseus builds user messages in OpenAI style::
|
||||
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "..."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}},
|
||||
]}
|
||||
|
||||
Native Ollama ``/api/chat`` does **not** accept a list for ``content``. It
|
||||
expects ``content`` to be a string and images carried separately on
|
||||
``images`` (a list of raw base64 strings, no ``data:`` prefix). Without
|
||||
this conversion the image block silently never reaches the vision model —
|
||||
the model reports "I can't see the image" even though it is vision-capable
|
||||
and the request succeeded.
|
||||
"""
|
||||
from src import llm_core
|
||||
|
||||
|
||||
def _multimodal_msg():
|
||||
return {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What is in this picture?"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,BBBB"}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_ollama_payload_converts_openai_image_blocks_to_native_images_array():
|
||||
payload = llm_core._build_ollama_payload(
|
||||
"gemma4:e4b", [_multimodal_msg()], temperature=0.0, max_tokens=0,
|
||||
)
|
||||
msg = payload["messages"][0]
|
||||
# Content must be a string, not a list — native Ollama rejects lists.
|
||||
assert isinstance(msg["content"], str)
|
||||
assert "What is in this picture?" in msg["content"]
|
||||
# Base64 data extracted into the native images array (no data: prefix).
|
||||
assert msg["images"] == ["AAAA", "BBBB"]
|
||||
|
||||
|
||||
def test_ollama_payload_skips_http_image_url():
|
||||
"""Non-data-URI image_url values are skipped with a warning because
|
||||
native Ollama images[] accepts base64 only."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Look"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
|
||||
],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert out["content"] == "Look"
|
||||
# HTTP URL is NOT added to images — Ollama cannot fetch it.
|
||||
assert "images" not in out
|
||||
|
||||
|
||||
def test_ollama_payload_preserves_native_images_array():
|
||||
"""If the caller already used Ollama's native shape, leave it alone."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": "Describe",
|
||||
"images": ["XXXX"],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert out["content"] == "Describe"
|
||||
assert out["images"] == ["XXXX"]
|
||||
|
||||
|
||||
def test_ollama_payload_merges_native_and_openai_images():
|
||||
"""A message that carries both native ``images`` and OpenAI ``image_url``
|
||||
blocks (e.g. assembled by different code paths) must produce one combined
|
||||
list rather than drop either half."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hi"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,OPENAI"}},
|
||||
],
|
||||
"images": ["NATIVE"],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert out["content"] == "Hi"
|
||||
assert out["images"] == ["NATIVE", "OPENAI"]
|
||||
|
||||
|
||||
def test_ollama_payload_text_only_message_untouched():
|
||||
msgs = [{"role": "user", "content": "hello"}]
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", msgs, temperature=0.0, max_tokens=0)
|
||||
assert payload["messages"][0] == {"role": "user", "content": "hello"}
|
||||
|
||||
|
||||
def test_ollama_payload_string_content_with_only_image_block():
|
||||
"""A message whose content list has only image_url blocks (no text part)
|
||||
still yields a non-empty content string so native Ollama accepts it."""
|
||||
msg = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,QQ=="}},
|
||||
],
|
||||
}
|
||||
payload = llm_core._build_ollama_payload("gemma4:e4b", [msg], temperature=0.0, max_tokens=0)
|
||||
out = payload["messages"][0]
|
||||
assert isinstance(out["content"], str)
|
||||
assert out["images"] == ["QQ=="]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Node-driven regression coverage for body-portaled dropdown z-order.
|
||||
|
||||
Tool-modal z climbs unbounded via modalManager's bring-to-front counter, so the
|
||||
old hardcoded `z-index: 10001` shared by ~16 body-portaled dropdowns eventually
|
||||
rendered them BEHIND their own modal in a long session (#4720). topPortalZ()
|
||||
replaces every one of those literals with a value derived from the live
|
||||
tool-window stack. These tests pin that it always clears both the modal stack
|
||||
and the dock-chip floor, without importing the browser-heavy UI modules.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HELPER = ROOT / "static" / "js" / "toolWindowZOrder.js"
|
||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
|
||||
|
||||
|
||||
def _node_eval(source: str):
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=source,
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
def test_portal_z_clears_dock_chip_floor_when_no_modal_is_open():
|
||||
# No tool window raised → topToolWindowZ floors at 250, but a portaled
|
||||
# dropdown must still clear the dock chips pinned up to 10030, so it lands
|
||||
# just above that floor.
|
||||
values = _node_eval(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||
const root = {{ querySelectorAll() {{ return []; }} }};
|
||||
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: () => ({{}}) }}) }}));
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
assert values == {"z": 10031}
|
||||
|
||||
|
||||
def test_portal_z_sits_above_a_modal_whose_counter_has_climbed_past_10001():
|
||||
# The #4720 scenario: a long session bumped the owning modal's bring-to-front
|
||||
# z to 99999. A hardcoded 10001 dropdown rendered BEHIND it; topPortalZ must
|
||||
# land one above the live modal z.
|
||||
values = _node_eval(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
|
||||
const modal = {{ id: 'memory-modal', classList: cls(), style: {{ zIndex: '99999' }} }};
|
||||
const root = {{ querySelectorAll() {{ return [modal]; }} }};
|
||||
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
assert values == {"z": 100000}
|
||||
|
||||
|
||||
def test_portal_z_uses_chip_floor_when_the_open_modal_sits_below_it():
|
||||
# A modal raised to 5000 is still below the dock-chip floor, so the floor
|
||||
# (10030) wins and the dropdown lands at 10031 — never below a pinned chip.
|
||||
values = _node_eval(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
import {{ topPortalZ }} from '{HELPER.as_uri()}';
|
||||
const cls = (...names) => ({{ contains: (name) => names.includes(name) }});
|
||||
const modal = {{ id: 'cookbook-modal', classList: cls(), style: {{ zIndex: '5000' }} }};
|
||||
const root = {{ querySelectorAll() {{ return [modal]; }} }};
|
||||
console.log(JSON.stringify({{ z: topPortalZ({{ root, getStyle: (el) => el.style }}) }}));
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
assert values == {"z": 10031}
|
||||
@@ -0,0 +1,49 @@
|
||||
r"""Regression test for ReDoS in the calendar-extract fallback regex.
|
||||
|
||||
CodeQL `py/redos` (#198) flagged the inline array-matcher in
|
||||
`email_pollers.py` that recovers a `[{"action": ...}, ...]` JSON array from
|
||||
raw LLM output (influenced by attacker-supplied email bodies). The original
|
||||
pattern used `[^[\]]*?` lazy runs inside a `(...)*` repetition, which
|
||||
backtracks *exponentially* on inputs like `[{"action"},{` + `}},{{` * N.
|
||||
|
||||
The regex is now a module-level constant so it can be pinned here. These tests
|
||||
assert it (a) still extracts well-formed action arrays and (b) returns
|
||||
promptly on the adversarial input that hung the old pattern.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from routes.email_pollers import _CAL_ACTION_ARRAY_RE
|
||||
|
||||
|
||||
def _matches(s):
|
||||
return [m.group() for m in _CAL_ACTION_ARRAY_RE.finditer(s)]
|
||||
|
||||
|
||||
def test_extracts_action_array_from_prose():
|
||||
s = 'Here you go:\n[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]\nThanks!'
|
||||
assert _matches(s) == ['[{"action":"add","title":"Standup","start":"2026-07-01T09:00"}]']
|
||||
|
||||
|
||||
def test_extracts_multi_object_array():
|
||||
s = 'prose [{"action":"add","title":"A"},{"action":"cancel","uid":"x"}] tail'
|
||||
assert _matches(s) == ['[{"action":"add","title":"A"},{"action":"cancel","uid":"x"}]']
|
||||
|
||||
|
||||
def test_no_array_returns_no_match():
|
||||
assert _matches("no array here at all") == []
|
||||
|
||||
|
||||
def test_bracket_in_string_value_still_extracts():
|
||||
# The old `[^[\]]` class bailed on a '[' inside a value and matched nothing;
|
||||
# the linear `[^{}]` form correctly recovers the array.
|
||||
s = '[{"action":"add","title":"Meeting [urgent]","start":"x"}]'
|
||||
assert _matches(s) == [s]
|
||||
|
||||
|
||||
def test_adversarial_input_is_fast():
|
||||
evil = '[{"action"},{' + '}},{{' * 100_000 # exploded the old exponential pattern
|
||||
start = time.perf_counter()
|
||||
_CAL_ACTION_ARRAY_RE.search(evil)
|
||||
dt = time.perf_counter() - start
|
||||
assert dt < 1.0, f"_CAL_ACTION_ARRAY_RE took {dt:.2f}s on adversarial input"
|
||||
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_opencode_setup_provider_aliases_resolve():
|
||||
source = Path("static/js/slashCommands.js").read_text()
|
||||
match = re.search(
|
||||
r"const SETUP_PROVIDER_URLS = \{[\s\S]*?\nfunction _normalizeSetupBaseUrl",
|
||||
source,
|
||||
)
|
||||
assert match, "setup provider helper block not found"
|
||||
helper_source = match.group(0).removesuffix("\nfunction _normalizeSetupBaseUrl")
|
||||
script = helper_source + r"""
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
const zenFromCommand = _setupProviderFromInput('opencode zen');
|
||||
assert(zenFromCommand && zenFromCommand.url === 'https://opencode.ai/zen/v1', 'opencode zen command alias failed');
|
||||
const goFromCommand = _setupProviderFromInput('opencode-go');
|
||||
assert(goFromCommand && goFromCommand.url === 'https://opencode.ai/zen/go/v1', 'opencode-go command alias failed');
|
||||
const zenCredential = _extractSetupProviderCredential('opencode-zen sk-test');
|
||||
assert(zenCredential && zenCredential.provider.name === 'OpenCode Zen', 'opencode-zen credential provider failed');
|
||||
assert(zenCredential.credential === 'sk-test', 'opencode-zen credential extraction failed');
|
||||
const goCredential = _extractSetupProviderCredential('opencode go sk-test');
|
||||
assert(goCredential && goCredential.provider.name === 'OpenCode Go', 'opencode go credential provider failed');
|
||||
assert(goCredential.credential === 'sk-test', 'opencode go credential extraction failed');
|
||||
"""
|
||||
subprocess.run(["node", "-e", script], check=True)
|
||||
Reference in New Issue
Block a user