Compare commits

..

7 commits
dev ... main

Author SHA1 Message Date
1a7d67df7a Add anti-wipe guard for hfToken in cookbook state sync
Some checks failed
CI / Focused test guidance (report-only) (push) Has been cancelled
CI / Python syntax (compileall) (push) Has been cancelled
CI / JS syntax (node --check) (push) Has been cancelled
CI / Python tests (pytest) (push) Has been cancelled
Container scan / hadolint (Dockerfile lint) (push) Has been cancelled
Container scan (Trivy) / Trivy (image scan, advisory) (push) Has been cancelled
Container scan (Trivy) / Trivy (image scan + SARIF upload) (push) Has been cancelled
Dependency review / dependency-review (PR gate) (push) Has been cancelled
Dependency review / pip-audit (advisory) (push) Has been cancelled
ci / docker publish / build (amd64) (push) Has been cancelled
ci / docker publish / build (arm64) (push) Has been cancelled
Secret scan / gitleaks (push) Has been cancelled
Workflow security / actionlint (push) Has been cancelled
Workflow security / zizmor (Actions SAST) (push) Has been cancelled
ci / docker publish / merge manifest + tag (push) Has been cancelled
The frontend strips hfToken from state responses (GET replaces it with masked
version). When it syncs back via POST, incoming state has no hfToken. The
servers anti-wipe guard existed but hfToken had no equivalent protection,
so the token could be wiped by a sync race. Added hfToken preservation
matching the servers guard pattern.
2026-07-07 00:39:50 -04:00
80f38c9443 Detect llama.cpp server readiness for auto-endpoint registration
Some checks are pending
CI / Focused test guidance (report-only) (push) Waiting to run
CI / Python syntax (compileall) (push) Waiting to run
CI / JS syntax (node --check) (push) Waiting to run
CI / Python tests (pytest) (push) Waiting to run
Container scan / hadolint (Dockerfile lint) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan, advisory) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan + SARIF upload) (push) Waiting to run
Dependency review / dependency-review (PR gate) (push) Waiting to run
Dependency review / pip-audit (advisory) (push) Waiting to run
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Secret scan / gitleaks (push) Waiting to run
Workflow security / actionlint (push) Waiting to run
Workflow security / zizmor (Actions SAST) (push) Waiting to run
_parseServePhase only recognized vLLM, FastAPI, and Ollama ready states.
llama-server's output ('server is listening on http://...') didn't match
any pattern, so _serveReady was never set and auto-registration never fired.
Added regex matching llamacpp-style listening messages.
2026-07-07 00:26:47 -04:00
6918d9417f Fix ReferenceError: streamingTTS is not defined in chat error handler
Some checks are pending
CI / Focused test guidance (report-only) (push) Waiting to run
CI / Python syntax (compileall) (push) Waiting to run
CI / JS syntax (node --check) (push) Waiting to run
CI / Python tests (pytest) (push) Waiting to run
Container scan / hadolint (Dockerfile lint) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan, advisory) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan + SARIF upload) (push) Waiting to run
Dependency review / dependency-review (PR gate) (push) Waiting to run
Dependency review / pip-audit (advisory) (push) Waiting to run
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Secret scan / gitleaks (push) Waiting to run
Workflow security / actionlint (push) Waiting to run
Workflow security / zizmor (Actions SAST) (push) Waiting to run
streamingTTS const is out of scope in the catch block at handleChatSubmit.
Replaced with direct window.aiTTSManager check using optional chaining.
2026-07-07 00:23:10 -04:00
9cbe6a165b Connect Ulysses to searxng network for search engine access
Some checks are pending
CI / Focused test guidance (report-only) (push) Waiting to run
CI / Python syntax (compileall) (push) Waiting to run
CI / JS syntax (node --check) (push) Waiting to run
CI / Python tests (pytest) (push) Waiting to run
Container scan / hadolint (Dockerfile lint) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan, advisory) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan + SARIF upload) (push) Waiting to run
Dependency review / dependency-review (PR gate) (push) Waiting to run
Dependency review / pip-audit (advisory) (push) Waiting to run
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Secret scan / gitleaks (push) Waiting to run
Workflow security / actionlint (push) Waiting to run
Workflow security / zizmor (Actions SAST) (push) Waiting to run
searxng runs on a separate Docker network (searxng_default) and the
container couldn't reach host-port 8888 due to Docker bridge isolation.
Added searxng_default as external network and attached odysseus service
to it, allowing direct container-to-container communication via
http://searxng:8080.
2026-07-06 23:24:00 -04:00
886d941a65 Point SEARXNG_INSTANCE to standalone searxng at host.docker.internal:8888
Some checks are pending
CI / Focused test guidance (report-only) (push) Waiting to run
CI / Python syntax (compileall) (push) Waiting to run
CI / JS syntax (node --check) (push) Waiting to run
CI / Python tests (pytest) (push) Waiting to run
Container scan / hadolint (Dockerfile lint) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan, advisory) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan + SARIF upload) (push) Waiting to run
Dependency review / dependency-review (PR gate) (push) Waiting to run
Dependency review / pip-audit (advisory) (push) Waiting to run
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Secret scan / gitleaks (push) Waiting to run
Workflow security / actionlint (push) Waiting to run
Workflow security / zizmor (Actions SAST) (push) Waiting to run
Bundled searxng was removed — the old http://searxng:8080 DNS name
resolves to nothing. Standalone searxng runs on Helm at port 8888,
reachable from container via host.docker.internal.
2026-07-06 22:29:11 -04:00
e7b5274af6 Add LD_LIBRARY_PATH=/app/.local/bin to container env for native llama.cpp binary
Some checks are pending
CI / Focused test guidance (report-only) (push) Waiting to run
CI / Python syntax (compileall) (push) Waiting to run
CI / JS syntax (node --check) (push) Waiting to run
CI / Python tests (pytest) (push) Waiting to run
Container scan / hadolint (Dockerfile lint) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan, advisory) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan + SARIF upload) (push) Waiting to run
Dependency review / dependency-review (PR gate) (push) Waiting to run
Dependency review / pip-audit (advisory) (push) Waiting to run
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Secret scan / gitleaks (push) Waiting to run
Workflow security / actionlint (push) Waiting to run
Workflow security / zizmor (Actions SAST) (push) Waiting to run
The manual native CUDA build's rpath points to /tmp/llama.cpp/build/bin/
(host build directory) instead of $ORIGIN. Setting LD_LIBRARY_PATH in the
Docker environment ensures the shared libraries are found inside the container
and survives container restarts (unlike the ephemeral ~/.config env file).
2026-07-06 21:56:56 -04:00
4b98744495 ulysses: remove bundled searxng — conflicts with standalone instance at :8888
Some checks are pending
CI / Focused test guidance (report-only) (push) Waiting to run
CI / Python syntax (compileall) (push) Waiting to run
CI / JS syntax (node --check) (push) Waiting to run
CI / Python tests (pytest) (push) Waiting to run
Container scan / hadolint (Dockerfile lint) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan, advisory) (push) Waiting to run
Container scan (Trivy) / Trivy (image scan + SARIF upload) (push) Waiting to run
Dependency review / dependency-review (PR gate) (push) Waiting to run
Dependency review / pip-audit (advisory) (push) Waiting to run
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Secret scan / gitleaks (push) Waiting to run
Workflow security / actionlint (push) Waiting to run
Workflow security / zizmor (Actions SAST) (push) Waiting to run
2026-07-06 21:26:20 -04:00
23 changed files with 145 additions and 636 deletions

221
.env.save
View file

@ -1,221 +0,0 @@
# Odysseus UI — Environment Configuration
# Copy this file to .env and fill in your values.
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
# ============================================================
# LLM Configuration
# ============================================================
# Primary LLM host (default: localhost)
LLM_HOST=localhost
# Additional LLM hosts, comma-separated (for model discovery)
# Use hostnames/IPs only; Odysseus scans common serve ports, including Ollama's 11434.
# LLM_HOSTS=llm-host.local,backup-llm.local
# Optional Ollama base URL. In Docker, host Ollama is usually reachable here
# when started with OLLAMA_HOST=0.0.0.0:11434.
# OLLAMA_BASE_URL=http://host.docker.internal:11434/v1
# Optional LM Studio URL. In Docker, host LM Studio is reachable here
# when LM Studio is set to serve on all interfaces (0.0.0.0).
# LM_STUDIO_URL=http://host.docker.internal:1234
# OpenAI API key (only needed if using OpenAI models).
# Do not commit real keys. Keep this commented until needed.
# OPENAI_API_KEY=your_openai_api_key_here
# Research service LLM endpoint
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
# Extra CA bundle for LLM providers whose TLS chain isn't in the default
# trust store. Layered ON TOP of the system / certifi bundle — verification
# stays on for every host, the trust set just gets larger. Useful for:
# - GigaChat / Sber (Russian Trusted Root CA): without this the endpoint
# shows offline with CERTIFICATE_VERIFY_FAILED — self-signed certificate
# in certificate chain.
# - On-premise / corporate LLM gateways with an internal CA.
# Point at a PEM file containing the missing root(s).
# LLM_CA_BUNDLE=/etc/odysseus/ca/extra-roots.pem
# ============================================================
# Search & Web
# ============================================================
# SearXNG instance URL (self-hosted, for web search).
# Docker Compose overrides this to http://searxng:8080 for in-network access.
SEARXNG_INSTANCE=http://localhost:8080
# Optional SearXNG cookie/CSRF secret. If blank, Docker generates one on first boot
# and stores it in the searxng-data volume.
# SEARXNG_SECRET=
# ============================================================
# Database
# ============================================================
# SQLite database path (default: sqlite:///./data/app.db)
# DATABASE_URL=sqlite:///./data/app.db
# ============================================================
# Data directory
# ============================================================
# Move everything that lives under data/ - settings, sessions, database, auth,
# cache, uploads, etc. - to another path:
# ODYSSEUS_DATA_DIR=C:\path\to\dir
# ============================================================
# Auth & Security
# ============================================================
# Enable authentication (default: true)
# AUTH_ENABLED=true
# Host bind address and port for the Odysseus web UI in Docker Compose.
# Keep APP_BIND on loopback unless you intentionally want LAN/reverse-proxy access.
APP_BIND=0.0.0.0
# Change this if another local service already uses 7000 (macOS AirPlay often does).
# APP_PORT=7000
# Development-only auth bypass for loopback requests.
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
# LOCALHOST_BYPASS=false
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS
# by a trusted reverse proxy or private access gateway.
# SECURE_COOKIES=true
# Optional: pre-seed the first admin password during setup.
# Do not commit a real password.
# ODYSSEUS_ADMIN_PASSWORD=change_me_before_first_boot
# CORS allowed origins (default: localhost-only; restrict to your public origin in production)
# ALLOWED_ORIGINS=http://localhost:7000,http://localhost:8000
# ============================================================
# ChromaDB (vector store)
# ============================================================
# ChromaDB service host.
# Manual host run: localhost:8100 when using `docker run -p 8100:8000 chromadb/chroma`.
# Docker Compose overrides these to chromadb:8000 for in-network access.
# CHROMADB_HOST=localhost
# CHROMADB_PORT=8100
# Docker Compose host-port bind addresses for bundled services.
# Defaults are loopback-only for safety. To expose ntfy only on Tailscale,
# set NTFY_BIND to your host's Tailscale IP and update NTFY_BASE_URL.
# CHROMADB_BIND=127.0.0.1
# NTFY_BIND=127.0.0.1
# NTFY_BASE_URL=http://localhost:8091
# Example:
# NTFY_BIND=100.x.y.z
# NTFY_BASE_URL=http://100.x.y.z:8091
# ============================================================
# RAG / Embeddings
# ============================================================
# Embedding API endpoint (OpenAI-compatible /v1/embeddings)
# Default: http://{LLM_HOST}:11434/v1/embeddings (ollama)
# EMBEDDING_URL=http://localhost:11434/v1/embeddings
# Embedding API key (if there's one)
# EMBEDDING_API_KEY=embedding_api_key_here
# Embedding model name (must be available at the endpoint above)
# EMBEDDING_MODEL=all-minilm:l6-v2
# Local fallback embedding model (used when no HTTP embedding API is available)
# Uses fastembed (ONNX) — downloads model on first run (~50MB)
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
# ============================================================
# Misc
# ============================================================
# Cleanup interval in hours (default: 24)
# CLEANUP_INTERVAL_HOURS=24
# In-process email pollers (default: on). Set to 0 if you're driving
# polling from cron / systemd via `scripts/odysseus-mail poll-scheduled`
# and `scripts/odysseus-mail poll-summary`, otherwise both schedulers
# race on the same SQLite.
# ODYSSEUS_INPROCESS_POLLERS=1
# In-process scheduled-task runner (default: on). Set to 0 to let an
# external driver fire scheduled tasks. Calendar reminders are
# frontend-driven (polling /api/notes from the browser) so no gate is
# needed there.
# ODYSSEUS_INPROCESS_TASKS=1
# Host used by the built-in "run_script" scheduled-task action.
# Empty/local/localhost runs scripts on the app host. Set to an SSH host alias
# if you intentionally want scheduled scripts to run remotely.
# ODYSSEUS_SCRIPT_HOST=localhost
# Chat / agent attachment size cap in bytes (default: 10 MB).
# Raise this for local installs that need larger PDFs or text documents.
# Example: 52428800 = 50 MB.
# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760
# Other per-feature upload size caps in bytes. All are validated and optional;
# defaults shown. An invalid value (non-integer or < 1) fails fast at startup.
# ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=104857600 # gallery image upload (100 MB)
# ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=26214400 # gallery transform input (25 MB)
# ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=10485760 # memory import file (10 MB)
# ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=26214400 # personal document upload (25 MB)
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
# ============================================================
# Host Docker access (explicit opt-in)
# ============================================================
# Default Docker Compose does not mount /var/run/docker.sock. Existing
# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it.
#
# Enable this only for intentional Cookbook/local Docker-daemon management.
# Raw socket access is high-trust and can grant broad control over the host
# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID.
# Put these values in .env, or export them before running docker compose.
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
# DOCKER_GID=963
# docker/host-docker.yml sets this inside the container. Keep it paired
# with the socket overlay; setting it alone is not sufficient.
# ODYSSEUS_ENABLE_HOST_DOCKER=true
#
# Host Docker access can be combined with one GPU overlay:
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
# ============================================================
# GPU support (Docker Compose)
# ============================================================
# Pass the host GPU into the odysseus container. Default (unset) = CPU.
# COMPOSE_FILE is a native `docker compose` feature: a colon-separated
# list of files merged left-to-right. Pick ONE GPU line below, or leave
# all commented for CPU.
#
# NVIDIA (requires nvidia-container-toolkit + `nvidia-ctk runtime
# configure --runtime=docker` on the host):
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
# COMPOSE_FILE=docker-compose.yml;docker/gpu.nvidia.yml #(Windows)
#
# AMD ROCm (requires ROCm drivers on the host and the GID of the render group):
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
# Find the render GID with: getent group render | cut -d: -f3
# RENDER_GID=989
#
# These overlays only expose the GPU devices. The slim Odysseus image
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
# llama-cpp-python, etc.) before models can actually serve on GPU.
# ============================================================
# Storage Paths (Docker Compose)
# ============================================================
# APP_DATA_DIR=./data
# APP_LOGS_DIR=./logs

View file

@ -382,11 +382,7 @@ class SessionManager:
else:
cached = self.sessions[session_id]
# Lazy hydrate: metadata-only entries get their messages on first read.
if not cached.history:
logger.info(
"[ctx-diag] lazy hydrating session=%s (history empty, msg_count=%d)",
session_id, getattr(cached, "message_count", 0),
)
if not cached.history and getattr(cached, "message_count", 0) > 0:
self._load_session_from_db(session_id)
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the

Binary file not shown.

View file

@ -85,8 +85,8 @@ services:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
depends_on:
# searxng:
# condition: service_healthy
searxng:
condition: service_healthy
chromadb:
condition: service_started
restart: unless-stopped
@ -109,55 +109,55 @@ services:
- ANONYMIZED_TELEMETRY=FALSE
restart: unless-stopped
# searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
# image: docker.io/searxng/searxng:2026.5.31-7159b8aed
# entrypoint:
# - /bin/sh
# - -c
# - |
# set -eu
# if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
# secret=\"$${SEARXNG_SECRET:-}\"
# if [ -z \"$$secret\" ]; then
# secret=\"$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')\"
# fi
# sed \"s|__SEARXNG_SECRET__|$$secret|g\" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
# fi
# exec /usr/local/searxng/entrypoint.sh
# ports:
# - \"127.0.0.1:8080:8080\"
# volumes:
# - searxng-data:/etc/searxng
# - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
# environment:
# - SEARXNG_BASE_URL=http://localhost:8080/
# - SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
# cap_drop:
# - ALL
# cap_add:
# - CHOWN
# - SETGID
# - SETUID
# - DAC_OVERRIDE
# healthcheck:
# test: [\"CMD-SHELL\", \"python -c \\\"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\\\"\"]\"
# interval: 5s
# timeout: 6s
# retries: 20
# start_period: 10s
# restart: unless-stopped
searxng:
# Pinned, not :latest — odysseus waits on searxng's healthcheck
# (depends_on: condition: service_healthy), so a broken upstream `latest`
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
# Bump this deliberately after verifying a newer tag boots clean.
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
entrypoint:
- /bin/sh
- -c
- |
set -eu
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
secret="$${SEARXNG_SECRET:-}"
if [ -z "$$secret" ]; then
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
# The official searxng image runs as the non-root `searxng` user, but its
# entrypoint still needs to chown /etc/searxng on first boot, drop privs via
# su-exec, and (with our wrapper above) write settings.yml into the named
# volume. Without these capabilities the wrapper aborts at the redirection
# with EACCES and the container fails its healthcheck with permission
# errors during setup. Mirrors the cap set recommended by the upstream
# searxng-docker compose file. See issue #721.
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
interval: 5s
timeout: 6s
retries: 20
start_period: 10s
restart: unless-stopped
ntfy:
image: docker.io/binwiederhier/ntfy
@ -171,6 +171,6 @@ services:
restart: unless-stopped
volumes:
# searxng-data:
searxng-data:
chromadb-data:
ntfy-cache:

View file

@ -1,66 +0,0 @@
# Ulysses Improvement Tasks
Last updated: 2026-07-08
## Priority: HIGH
- [ ] **Agent toolcalling loop: model narrates actions but never emits fenced blocks**
Gemma-4-12b repeatedly narrates intent ("I'm calling list_vault now") without emitting the ```tool fenced block. The model clearly understands what it should do but the fenced block never arrives. This blocks agent workflows entirely when it occurs (~25% of tool calls). The anti-hallucination system prompt rule drafted yesterday is staged but not live (container not restarted). Need to investigate whether this is: (a) the missing system prompt rule, (b) a regex/parsing issue in parse_tool_blocks(), (c) the compact API-model prompt confusing non-tool-native models, or (d) a context/truncation issue preventing the fenced block from reaching the parser.
- [ ] **Chat deletion dropdown broken — does not appear**
Clicking the arrow on a chat in the left drawer no longer shows the dropdown menu (rename/delete/etc.). Cannot delete chats. Likely a JS event listener or DOM positioning issue — possibly zoom-related getBoundingClientRect() skew or a regression from recent dropdown/popup fixes.
- [ ] **AUTO_START_LOCAL: persist & auto-restore last running model on restart**
New `AUTO_START_LOCAL` env var (bool, default false). On graceful shutdown/restart, persist the currently running model + all launch parameters (engine, quantization, context length, GPU layers, etc.) to a file in `/app/data/`. On startup, if the file exists and AUTO_START_LOCAL is true, automate the full launch pipeline: resolve the saved preset, spin up the engine, and register the endpoint — skipping the manual cookbook → Launch → pick model → verify params → launch → wait → refresh cycle the user currently endures on every restart.
- [ ] **Floating windows overflow viewport after zoom/font changes — unreachable controls**
`:root.ui-scale-125 { zoom: 1.25 }` renders all content 25% larger, but `window.innerHeight` doesn't change with `zoom` (CSS rendering only, not DOM). The `windowResize.js` saved-size restore at line 224 clamps to `window.innerHeight`, which after zoom makes windows 1.25× the visible viewport — pushing headers, close buttons, and top resize handles off-screen. The user literally can't close or move them. Fix: (a) divide restored height by zoom factor in the restore path; (b) add a general safety net — any `.modal-content` on open should clamp to `calc(100dvh / var(--ui-zoom, 1) - 40px)`. Also `windowResize.js` line 134 `if (top + height > vh)` needs zoom-aware `vh`. Current zoom compensation rules (line 181-196) only cover `.modal-content`, `.cal-modal-content`, `.settings-modal-content`, `#theme-popup`, `#cookbook-modal`, and `.pdf-export-overlay` — many windows lack the divisor.
- [x] **Fix manage_documents tool schema — missing "read" action**
The `manage_documents` function-calling schema in `tool_schemas.py` had `"enum": ["list", "delete", "tidy"]` but the handler in `document_tools.py` supports `"read", "view", "open", "get"`. The model couldn't read documents through function calling. Fixed: added read/view/open/get to enum, added `document_id`/`offset`/`limit` params with read-specific descriptions, updated description with "DOCUMENT PANEL (not disk)" prefix. Also added disambiguation prefix to `create_document` and `edit_document` schemas.
- [x] **Add "Send to Chat" button to document editor**
Button left of the Save button in `#doc-actions-footer`. Sends a document reference (title + doc_id + snippet) to the active chat, similar to how Cursor injects file references. Must NOT paste full content — just a reference the agent can resolve. Done: "Chat" button injects `[Title](#document-<id>) — <120-char snippet>` into the #message textarea.
## Priority: MEDIUM
- [ ] **Bolster Library/Documents CRUD via MCP or native tools**
Current document tools are solid but disjointed from agent-mode workflow. The agent has filesystem access (`read_file`, `write_file`, `edit_file`, `glob`, `ls`, `grep`, `bash`) but these tools operate on disk files, not Ulysses library documents. Need to bridge the gap: the agent should be able to list, read, grep, and edit library documents as naturally as files. Consider:
- A unified `read_document` tool distinct from `manage_documents` (which is an admin-style multi-action tool)
- A `grep_documents` or `search_documents` tool for semantic/fulltext search
- Auto-inject active document content into agent context when relevant
- [ ] **Deep Research: "Send to Chat" reference + agent MCP tool to read reports**
Two-part feature: **(1) UI** — Add a button (alongside "Discuss") to inject a research reference (`[Title](#research-<id>)`) into the active chat's message input, matching the document "Chat" button pattern. Applies to: research panel sidebar (panel.js:1012), research library cards (documentLibrary.js:2767), and inline result messages (chatRenderer.js:1070). **(2) Agent tooling** — Research reports are HTML artifacts served at `/api/research/report/<id>`, not library documents. The agent currently can't resolve a research reference — pasting an ID gets a blank stare. Need an MCP tool (e.g. `read_research` or `get_research_report`) that accepts a research ID, fetches the report HTML, strips it to readable text, and returns it to the agent. Without this, the "Send to Chat" reference is dead text.
- [ ] **First-party document support in agent mode**
Documents feel "disjointed from the agent." The agent creates documents fine but can't easily discover or reference existing ones contextually. Ideas:
- "Agent workspace" awareness — automatically surface relevant documents based on chat context
- Document references in chat that are clickable and auto-expand
- The "Send to Chat" button (HIGH priority) is step 1; step 2 is making the agent proactively aware of library contents
- [x] **Improve `create_document` schema description**
Current description is verbose and overlaps with `edit_file`/`write_file` disambiguation. Models sometimes use `create_document` for disk files. Fixed: added "DOCUMENT PANEL (not disk)" prefix, explicit "For files on disk use write_file" at end. Also applied to `edit_document`.
## Priority: LOW
- [ ] **Cookbook sliders: active highlight misaligned with zoom**
The rounded highlight on CPU/GPU/RAM etc. toggle sliders doesn't scale with `ui-scale-125`. The active indicator (e.g. "GPU") is partially outside the highlight pill. Low priority — cosmetic, obvious which option is selected.
- [ ] **Document library: batch operations**
Multi-select, bulk delete, bulk export. Currently only single-document operations.
- [ ] **Document version diff view**
The version history panel shows versions but doesn't offer a side-by-side diff view between versions.
- [ ] **Auto-language detection on document open**
The editor auto-detects language on typing but not on initial document open from the library. Opening a Python file saved as "markdown" should suggest the language switch.
---
## Completed
<!-- Move completed items here with completion date -->
- [x] **2026-07-08: Edit message textarea now fills full message width**
`.msg-user { width: fit-content }` collapsed container when body swapped for textarea. CSS: `.msg-user.editing { width: 85% !important }`. JS: `editUserMessage()` adds/removes `editing` class. (commit f0031ea)

View file

@ -751,13 +751,7 @@ async def build_chat_context(
sess.model = norm
# Build messages
_ctx_msgs = sess.get_context_messages()
logger.info(
"[ctx-diag] session=%s history_len=%d ctx_messages=%d preface=%d roles=%s",
session_id, len(sess.history), len(_ctx_msgs), len(preface),
[m.get("role") for m in _ctx_msgs[-5:]] if _ctx_msgs else [],
)
messages = preface + _ctx_msgs
messages = preface + sess.get_context_messages()
# Current date/time — injected as a standalone *user*-role context message
# placed immediately before the latest user turn, NOT folded into the

View file

@ -2698,7 +2698,7 @@ def setup_cookbook_routes() -> APIRouter:
logger.warning("cookbook state POST: incoming env.servers empty; preserved on-disk servers (anti-wipe guard)")
if disk_env.get("hfToken") and not inc_env.get("hfToken"):
inc_env["hfToken"] = disk_env["hfToken"]
logger.warning("cookbook state POST: incoming env.hfToken empty; preserved on-disk hfToken (anti-wipe guard)")
logger.debug("cookbook state POST: incoming env missing hfToken; preserved on-disk token (anti-wipe guard)")
disk_tasks = on_disk.get("tasks") or [] if isinstance(on_disk, dict) else []
incoming_tasks = data.get("tasks") if isinstance(data.get("tasks"), list) else []

View file

@ -67,8 +67,7 @@ _AGENT_PREAMBLE = """\
You are an AI assistant with tool access. You can run shell commands, execute Python, search the web, \
read/write files, create and edit documents, generate images, manage memories, and more. \
To use a tool, write a fenced code block with the tool name as the language tag. \
The block executes automatically and you see the output. \
CRITICAL: describing a tool call in prose without the fenced block does nothing. Always emit the fenced block for any action."""
The block executes automatically and you see the output."""
_AGENT_RULES = """\
## Rules
@ -180,8 +179,7 @@ _API_AGENT_RULES = """\
_AGENT_PREAMBLE = """\
You are an AI assistant with tool access. Only the tools listed below are available for this turn.
To use a tool, write a fenced code block with the tool name as the language tag. The block executes automatically and you see the output.
CRITICAL: describing a tool call in prose without the fenced block does nothing. Always emit the fenced block for any action."""
To use a tool, write a fenced code block with the tool name as the language tag. The block executes automatically and you see the output."""
_AGENT_RULES = """\
## Base rules
@ -1504,8 +1502,7 @@ def _build_system_prompt(
"knowing the user's personal style."
)
_doc_message = untrusted_context_message("active editor document", doc_ctx)
if _doc_message:
_doc_message["_protected"] = True
_doc_message["_protected"] = True
# Auto-detect suggestion mode
_last_user_msg = ""
@ -1585,8 +1582,7 @@ def _build_system_prompt(
f"open email's sender.\n"
)
_email_message = untrusted_context_message("active email reader", email_ctx)
if _email_message:
_email_message["_protected"] = True
_email_message["_protected"] = True
# Inject writing style for any email writing path. This is deliberately
# broader than read/list: models may compose via send_email, reply_to_email,
@ -2914,54 +2910,6 @@ async def stream_agent_loop(
r"\b[^.\n]{0,140}",
re.IGNORECASE,
)
# Map intent verbs → likely tool names, filtered against available tools.
# Used by the intent-without-action supervisor to give the model a concrete
# tool name instead of a vague "DO IT NOW."
_INTENT_TOOL_HINTS: Dict[str, List[str]] = {
"list": ["ls", "list_vault", "list_served_models", "list_sessions", "manage_documents"],
"scan": ["ls", "list_vault", "grep"],
"read": ["read_file", "manage_documents", "view_document"],
"view": ["read_file", "manage_documents", "view_document"],
"see": ["read_file", "manage_documents", "view_document"],
"look": ["read_file", "grep", "web_search"],
"show": ["read_file", "manage_documents"],
"search": ["web_search", "grep", "search_documents"],
"find": ["grep", "web_search", "search_documents"],
"query": ["web_search", "grep"],
"grep": ["grep"],
"write": ["write_file", "create_document"],
"create": ["create_document", "write_file"],
"edit": ["edit_document", "write_file"],
"tail": ["tail_serve_output", "list_served_models"],
"check": ["read_file", "grep", "web_search", "list_served_models"],
"investigate": ["read_file", "grep", "web_search", "bash"],
"examine": ["read_file", "grep", "bash"],
"debug": ["bash", "python", "read_file", "grep"],
"run": ["bash", "python"],
"execute": ["bash", "python"],
"start": ["launch_model", "serve_model"],
"launch": ["launch_model", "serve_model"],
"call": [], # too generic — model usually names the tool explicitly
"fetch": ["web_fetch", "read_file"],
"pull": ["web_fetch", "read_file"],
"inspect": ["read_file", "grep", "bash"],
"verify": ["read_file", "grep", "bash"],
"diagnose": ["bash", "read_file", "grep"],
"capture": ["read_file", "grep", "bash"],
"grab": ["read_file", "web_fetch"],
"test": ["bash", "python"],
"use": [], # too generic
"do": [], # too generic
"perform": [], # too generic
"serve": ["serve_model", "list_served_models"],
"register": ["register_endpoint"],
"adopt": ["register_endpoint"],
"kill": ["bash"],
"stop": ["bash"],
"restart": ["bash"],
"kick": ["bash"],
"trigger": ["bash", "python"],
}
_awaiting_user = False # set by ask_user → end the turn and wait for a choice
# Document streaming state (persists across rounds)
@ -3483,94 +3431,24 @@ async def stream_agent_loop(
"session_id from the serve/list result. Never answer with "
"\"check logs\" when those tools are available."
)
# Find the most specific tool hint from the intent phrase.
_hint_tools: List[str] = []
_avail = _relevant_tools or set()
for _verb, _tools in _INTENT_TOOL_HINTS.items():
if f" {_verb}" in f" {_lower_phrase}" and _tools:
_hint_tools = [t for t in _tools if t in _avail][:3]
if _hint_tools:
break
# Also check if the model named a specific tool
_named_match = re.search(
r'(?:call|run|use|trigger)\s+(?:the\s+)?`?(\w[\w_-]+)`?',
_matched_phrase, re.IGNORECASE,
)
if _named_match:
_named_tool = _named_match.group(1).lower()
if _named_tool in _avail:
_hint_tools.insert(0, _named_tool)
_tool_hint = ""
if _hint_tools:
_tool_list = "`, `".join(_hint_tools[:3])
_tool_hint = f" Call `{_tool_list}`. "
messages.append({
"role": "system",
"content": (
f"You just wrote: \"{_matched_phrase}\" — but ended the "
"turn without making the actual tool call." + _tool_hint +
" The user can see you announced the action but didn't run "
"it, which is the most frustrating thing you can do. "
"DO IT NOW: emit the function call this turn. "
+ _cookbook_log_hint +
"turn without making the actual tool call. The user can "
"see you announced the action but didn't run it, which "
"is the most frustrating thing you can do. "
"DO IT NOW: emit the actual function call this turn. "
f"{_cookbook_log_hint}"
"If you decided not to do it after all, say so plainly in "
"one sentence instead of restating the plan."
),
})
# Visible signal in the stream so the user knows we caught it.
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
continue
break # no tools — done
# ── Wrong-tool detector ───────────────────────────────────────
# When the model DID emit a tool call but the prose describes a
# DIFFERENT action. Common on smaller models: says "I'll list the
# vault" but emits rename_session. Compare intent against actual
# tool blocks and flag mismatches.
_intent_text_wt = _strip_think_blocks(cleaned_round).strip()
_intent_match_wt = _INTENT_RE.search(_intent_text_wt) if _intent_text_wt else None
if _intent_match_wt and tool_blocks and not guide_only and not _force_answer:
_matched_wt = _intent_match_wt.group(0).strip().lower()
_actual_tools = {b.tool_type for b in tool_blocks}
_suggested: Set[str] = set()
for _verb, _tools in _INTENT_TOOL_HINTS.items():
if f" {_verb}" in f" {_matched_wt}":
_suggested.update(_tools)
_named_wt = re.search(
r'(?:call|run|use|trigger)\s+(?:the\s+)?`?(\w[\w_-]+)`?',
_matched_wt, re.IGNORECASE,
)
if _named_wt:
_suggested.add(_named_wt.group(1).lower())
if _suggested and not (_suggested & _actual_tools):
logger.info(
f"[agent] wrong-tool-detect round {round_num}: "
f"intent suggests {sorted(_suggested)[:5]}, actual={sorted(_actual_tools)}"
)
_avail_wt = _relevant_tools or set()
_candidates = sorted(_suggested & _avail_wt)[:3]
if _candidates:
_cand_list = "`, `".join(_candidates)
logger.info(
f"[agent] wrong-tool: {sorted(_actual_tools)}{_candidates}"
f"skipping execution, injecting correction"
)
messages.append({
"role": "system",
"content": (
f"You just called `{'`, `'.join(sorted(_actual_tools))}` "
f"but your description said you wanted to: \"{_matched_wt}\". "
f"These are not the same thing. The tool you likely need is "
f"`{_cand_list}`. Call that instead."
),
})
_empty_round = round_response.strip() or "(model attempted wrong tool call)"
_append_tool_results(
messages, _empty_round, [], [], [], False, round_num, round_reasoning,
)
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
continue
# ── Loop-breaker (Terminus-style stall detector) ──────────────
# Stall detector for repeated no-progress tool loops.
# A round is "useless" ONLY when it re-issues a recent tool call AND

View file

@ -61,7 +61,7 @@ def _sanitize_label(label: str) -> str:
return label
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any] | None:
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
"""Return an LLM message that keeps retrieved/source text out of system role.
The template is structured so that *only* the hardcoded
@ -69,15 +69,9 @@ def untrusted_context_message(label: str, content: Any) -> Dict[str, Any] | None
caller-derived text is placed in the pre-guard trusted framing zone.
The source label and the body content are both placed *inside* the
guarded block where the LLM treats them as untrusted data.
Returns None when content is empty or whitespace-only avoids
injecting blank wrapper blocks that confuse the model into thinking
the user pasted a placeholder template.
"""
text = "" if content is None else str(content).strip()
if not text:
return None
safe_label = _sanitize_label(label)
text = "" if content is None else str(content)
text = _escape_guard_markers(text)
return {
"role": "user",

View file

@ -187,7 +187,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "create_document",
"description": "DOCUMENT PANEL (not disk) — Create a new document in the editor panel. Use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines) AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large code blocks directly in chat — use this tool instead. For files on disk use write_file.",
"description": "Create a new document in the editor panel. Use this when the user asks to write, create, build, or generate code, scripts, programs, games, apps, or any substantial content (>15 lines) AND there is no already-open document/email draft that the request refers to. If an email compose draft is open, edit that draft instead of creating another document. NEVER put large code blocks directly in chat — use this tool instead.",
"parameters": {
"type": "object",
"properties": {
@ -203,7 +203,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "edit_document",
"description": "DOCUMENT PANEL (not disk) — Edit a document OPEN IN THE EDITOR PANEL (created via create_document) — NOT a file on disk. For files on disk (home folder, project files, anything with a path like ~/x.txt or /path/to/file) use edit_file instead. Targeted find-and-replace with multiple FIND/REPLACE pairs per call; use for any edit smaller than a full rewrite. Do NOT send the whole file back via update_document for small edits.",
"description": "Edit a document OPEN IN THE EDITOR PANEL (created via create_document) — NOT a file on disk. For files on disk (home folder, project files, anything with a path like ~/x.txt or /path/to/file) use edit_file instead. Targeted find-and-replace with multiple FIND/REPLACE pairs per call; use for any edit smaller than a full rewrite. Do NOT send the whole file back via update_document for small edits.",
"parameters": {
"type": "object",
"properties": {
@ -752,16 +752,15 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "manage_documents",
"description": "DOCUMENT PANEL (not disk) — List, read, delete, or tidy library documents. Use action=list to find documents, then action=read with document_id to view one (supports offset/limit for pagination). The document opens in the editor panel — click its title link or use action=read. Do NOT use read_file for library documents; use this tool instead.",
"description": "Manage documents: list all documents (with optional search/language filter), delete documents, or run tidy cleanup.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "read", "view", "open", "get", "delete", "tidy"]},
"document_id": {"type": "string", "description": "Document ID (for read, delete)"},
"action": {"type": "string", "enum": ["list", "delete", "tidy"]},
"document_id": {"type": "string", "description": "Document ID (for delete)"},
"search": {"type": "string", "description": "Search query (for list)"},
"language": {"type": "string", "description": "Filter by language (for list)"},
"limit": {"type": "integer", "description": "Max results (for list, default 50) or max chars to read (for read, default 8000)"},
"offset": {"type": "integer", "description": "Char offset for paginated reading (for read, default 0)"}
"limit": {"type": "integer", "description": "Max results (for list, default 50)"}
},
"required": ["action"]
}

View file

@ -1909,20 +1909,19 @@ function initializeEventListeners() {
// genuinely taller than the room above the button.
function positionMenu() {
const r = plusBtn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.left = (r.left / z) + 'px';
menu.style.left = r.left + 'px';
menu.style.right = 'auto';
menu.style.bottom = 'auto';
menu.style.maxHeight = ''; // reset so we can measure the natural height
menu.style.overflowY = '';
const avail = (r.top / z) - 16; // room above the chevron (viewport-relative)
const avail = r.top - 16; // room above the chevron
const natural = menu.scrollHeight;
const h = Math.min(natural, avail);
if (natural > avail) { // only cap + scroll when it doesn't fit
menu.style.maxHeight = avail + 'px';
menu.style.overflowY = 'auto';
}
menu.style.top = ((r.top / z) - 8 - h) + 'px';
menu.style.top = (r.top - 8 - h) + 'px';
}
// Tapping the chevron must NOT steal focus from the message box, or the
// mobile keyboard collapses. preventDefault on pointerdown keeps the

View file

@ -3036,7 +3036,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
} else {
// Stop streaming TTS on any error/abort
if (streamingTTS && window.aiTTSManager) window.aiTTSManager.stop();
if (window.aiTTSManager?.autoPlay && window.aiTTSManager?.available) window.aiTTSManager.stop();
if (currentAbort && currentAbort.signal.aborted) {
const abortReason = currentAbort._reason || '';
@ -4018,12 +4018,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
bodyEl.innerHTML = '';
bodyEl.appendChild(editor);
bodyEl.appendChild(btnRow);
userMsgElement.classList.add('editing');
editor.focus();
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
userMsgElement.classList.remove('editing');
bodyEl.innerHTML = originalHTML;
});
@ -4056,7 +4054,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} catch (err) {
console.error('Edit failed:', err);
if (uiModule) uiModule.showError('Edit failed: ' + err.message);
userMsgElement.classList.remove('editing');
bodyEl.innerHTML = originalHTML;
}
});

View file

@ -293,11 +293,10 @@ function commitCurrent() {
function position(p, anchor) {
const rect = anchor.getBoundingClientRect();
const pRect = p.getBoundingClientRect();
const zc = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
let left = rect.left / zc;
let top = (rect.bottom / zc) + 6;
let left = rect.left;
let top = rect.bottom + 6;
if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8;
if (top + pRect.height > window.innerHeight - 8) top = (rect.top / zc) - pRect.height - 6;
if (top + pRect.height > window.innerHeight - 8) top = rect.top - pRect.height - 6;
if (left < 8) left = 8;
if (top < 8) top = 8;
p.style.left = left + 'px';

View file

@ -408,7 +408,8 @@ export function _parseServePhase(snapshot) {
if (/Ollama API ready on port\s+\d+/i.test(flat)) {
return { phase: 'ready', status: 'ready' };
}
if (/(?:server is |^|\s)listening on http:\/\//i.test(flat)) {
// llama.cpp server (llama-server / llama-cli) — "server is listening" variants
if (/(?:server is listening|HTTP server listening|listening on (?:http|port))/i.test(flat)) {
return { phase: 'ready', status: 'ready' };
}
const llamaBuildMatches = [...flat.matchAll(/\[\s*(\d{1,3})%\]\s*(?:Building|Linking)/gi)];
@ -2528,10 +2529,9 @@ export function _renderRunningTab() {
}
const rect = menuBtn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
dropdown.style.position = 'fixed';
dropdown.style.top = (rect.bottom / z) + 2 + 'px';
dropdown.style.right = (window.innerWidth - (rect.right / z)) + 'px';
dropdown.style.top = rect.bottom + 2 + 'px';
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
document.body.appendChild(dropdown);
// Clamp into the *visible* area. On mobile (esp. Firefox) window.innerHeight
// includes the strip hidden under the dynamic toolbar, so a menu that "fits"
@ -2544,9 +2544,9 @@ export function _renderRunningTab() {
const viewBottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
const dh = dropdown.offsetHeight;
const m = 8;
let top = rect.bottom / z + 2;
let top = rect.bottom + 2;
if (top + dh > viewBottom - m) {
const above = rect.top / z - 2 - dh;
const above = rect.top - 2 - dh;
top = above >= viewTop + m ? above : Math.max(viewTop + m, viewBottom - dh - m);
}
dropdown.style.top = top + 'px';

View file

@ -1077,8 +1077,7 @@ function _rerenderCachedModels() {
cancelDiv.addEventListener('click', () => { closeDropdown(); });
dropdown.appendChild(cancelDiv);
const rect = btn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth - (rect.right / z)}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
@ -1090,9 +1089,9 @@ function _rerenderCachedModels() {
const viewBottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
const dh = dropdown.offsetHeight;
const mm = 8;
let top = rect.bottom / z + 2;
let top = rect.bottom + 2;
if (top + dh > viewBottom - mm) {
const above = rect.top / z - 2 - dh;
const above = rect.top - 2 - dh;
top = above >= viewTop + mm ? above : Math.max(viewTop + mm, viewBottom - dh - mm);
}
dropdown.style.top = top + 'px';
@ -2328,11 +2327,10 @@ function _rerenderCachedModels() {
// Clamp into the viewport using the menu's real size (both axes); flip
// above the toggle if there isn't room below. Right-align to the anchor.
const w = dropdown.offsetWidth, h = dropdown.offsetHeight;
const za = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
let left = Math.min((rect.right / za) - w, window.innerWidth - w - 8);
let left = Math.min(rect.right - w, window.innerWidth - w - 8);
left = Math.max(8, left);
let top = (rect.bottom / za) + 6;
if (top + h > window.innerHeight - 8) top = Math.max(8, (rect.top / za) - 6 - h);
let top = rect.bottom + 6;
if (top + h > window.innerHeight - 8) top = Math.max(8, rect.top - 6 - h);
dropdown.style.left = `${left}px`;
dropdown.style.top = `${top}px`;
dropdown.style.visibility = '';
@ -2434,9 +2432,8 @@ function _rerenderCachedModels() {
menu.appendChild(mk('Clear Server', 'cookbook-dropdown-danger', () => _clearBtn?.click()));
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
const r = _launchMoreBtn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.position = 'fixed';
menu.style.right = (window.innerWidth - (r.right / z)) + 'px';
menu.style.right = (window.innerWidth - r.right) + 'px';
document.body.appendChild(menu);
{
const vv = window.visualViewport;
@ -2481,9 +2478,8 @@ function _rerenderCachedModels() {
menu.appendChild(mk('Probe GPUs', '', () => _probeBtn?.click()));
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
const r = _splitArrow.getBoundingClientRect();
const z2 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.position = 'fixed';
menu.style.right = (window.innerWidth - (r.right / z2)) + 'px';
menu.style.right = (window.innerWidth - r.right) + 'px';
document.body.appendChild(menu);
// Default open BELOW, but if there's no room (esp. on mobile where
// the arrow sits near the bottom of the modal) flip ABOVE so the
@ -2590,13 +2586,12 @@ function _rerenderCachedModels() {
// popup stays fully visible — GPU buttons near the right edge
// of the modal previously anchored the popup mostly off-screen.
const r = anchorBtn.getBoundingClientRect();
const z3 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
const vw = window.innerWidth || document.documentElement.clientWidth;
const vh = window.innerHeight || document.documentElement.clientHeight;
const pw = popup.offsetWidth || 320;
const ph = popup.offsetHeight || 200;
let left = r.left / z3;
let top = (r.bottom / z3) + 4;
let left = r.left;
let top = r.bottom + 4;
// Push left so the popup doesn't overflow the right edge.
if (left + pw > vw - 8) left = Math.max(8, vw - pw - 8);
// If there isn't room below, render above the button instead.

View file

@ -667,20 +667,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
}
function _sendToChat() {
if (!activeDocId) return;
const doc = docs.get(activeDocId);
if (!doc) return;
const ref = `[${doc.title || 'Untitled'}](#document-${activeDocId})`;
const msgEl = document.getElementById('message');
if (!msgEl) return;
const current = msgEl.value.trim();
msgEl.value = current ? `${current}\n\n${ref}` : ref;
msgEl.focus();
// Trigger input event so the textarea auto-resizes and any listeners fire
msgEl.dispatchEvent(new Event('input', { bubbles: true }));
}
async function _openExportPdfModal() {
if (!activeDocId) return;
await _saveActiveDocBeforeExport();
@ -4610,7 +4596,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
pinned to the bottom no matter which pane (editor / md-preview /
csv / html / pdf) is the one growing to fill. -->
<div id="doc-actions-footer" class="doc-email-actions">
<button type="button" id="doc-send-to-chat-btn" class="doc-action-icon-btn" title="Send document reference to chat" style="gap:4px;margin-right:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span style="font-size:11px;">Chat</span></button>
<span class="email-send-split" id="doc-copy-export-split">
<button type="button" id="doc-footer-copy-btn" class="email-send-btn email-send-main" title="Save new version" data-mode="save"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>
<button type="button" id="doc-footer-export-btn" class="email-send-btn email-send-caret" title="Export as…" aria-label="Export options"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg></button>
@ -4814,7 +4799,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
else saveDocument({ silent: false, forceVersion: true });
});
document.getElementById('doc-footer-export-btn')?.addEventListener('click', (e) => showExportMenu(null, e.currentTarget.getBoundingClientRect()));
document.getElementById('doc-send-to-chat-btn')?.addEventListener('click', () => _sendToChat());
// Mobile footer: Close the current doc + Copy its content (replaces the
// per-tab × on small screens, mirroring the email reader's Close footer).
document.getElementById('doc-mobile-close')?.addEventListener('click', () => { if (activeDocId) closeTab(activeDocId); });
@ -10006,7 +9990,24 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
function _handleMarkdownPreviewClickHint() {
// Intentionally removed — click-to-highlight while reading is normal behavior
if (!_isMarkdownPreviewVisible()) return;
const lang = ((docs.get(activeDocId)?.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
if (lang !== 'markdown') return;
const now = Date.now();
_mdPreviewClickTimes = _mdPreviewClickTimes.filter(ts => now - ts < 2500);
_mdPreviewClickTimes.push(now);
if (_mdPreviewClickTimes.length < 3 || now - _mdPreviewHintLastAt < 5000) return;
_mdPreviewHintLastAt = now;
_mdPreviewClickTimes = [];
if (uiModule?.showToast) {
uiModule.showToast('Preview is read-only. Click Write to edit the document.', {
duration: 5000,
action: 'Write',
onAction: () => _setMarkdownPreviewActive(false, { remember: true }),
});
}
}
function _refreshMarkdownPreviewIfVisible(docId, content) {

View file

@ -157,10 +157,9 @@ function togglePicker(anchor, target) {
document.body.appendChild(_pickerEl);
const rect = anchor.getBoundingClientRect();
const ze = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
_pickerEl.style.position = 'fixed';
_pickerEl.style.top = ((rect.bottom / ze) + 4) + 'px';
_pickerEl.style.left = (rect.left / ze) + 'px';
_pickerEl.style.top = (rect.bottom + 4) + 'px';
_pickerEl.style.left = rect.left + 'px';
_pickerEl.style.zIndex = String(topPortalZ());
requestAnimationFrame(() => {

View file

@ -825,18 +825,17 @@ function createSessionItem(s) {
} else {
// Position the dropdown using viewport coords
const rect = menuBtn.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
dropdown.style.left = '';
dropdown.style.right = (window.innerWidth - rect.right / z) + 'px';
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
// Show off-screen first to measure height
dropdown.style.top = '-9999px';
dropdown.style.display = 'block';
const ddRect = dropdown.getBoundingClientRect();
// Flip above if not enough room below
if (rect.bottom / z + 2 + ddRect.height / z > window.innerHeight) {
dropdown.style.top = Math.max(2, rect.top / z - ddRect.height / z - 2) + 'px';
if (rect.bottom + 2 + ddRect.height > window.innerHeight) {
dropdown.style.top = Math.max(2, rect.top - ddRect.height - 2) + 'px';
} else {
dropdown.style.top = rect.bottom / z + 2 + 'px';
dropdown.style.top = rect.bottom + 2 + 'px';
}
}
});
@ -2670,15 +2669,14 @@ function _showDropdown(anchorEl, items) {
// Position using viewport coords (same pattern as session menus)
const rect = anchorEl.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
dd.style.right = (window.innerWidth - rect.right / z) + 'px';
dd.style.right = (window.innerWidth - rect.right) + 'px';
dd.style.top = '-9999px';
dd.style.display = 'block';
const ddRect = dd.getBoundingClientRect();
if (rect.bottom / z + 2 + ddRect.height / z > window.innerHeight) {
dd.style.top = Math.max(2, rect.top / z - ddRect.height / z - 2) + 'px';
if (rect.bottom + 2 + ddRect.height > window.innerHeight) {
dd.style.top = Math.max(2, rect.top - ddRect.height - 2) + 'px';
} else {
dd.style.top = (rect.bottom / z + 2) + 'px';
dd.style.top = (rect.bottom + 2) + 'px';
}
function close() { dd.remove(); }

View file

@ -443,14 +443,13 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
// bring-to-front counter climbs past the static value (#4720).
menu.style.zIndex = String(topPortalZ());
const r = btn.getBoundingClientRect();
const zs = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
menu.style.top = ((r.bottom / zs) + 4) + 'px';
menu.style.right = Math.max(6, window.innerWidth - (r.right / zs)) + 'px';
menu.style.top = (r.bottom + 4) + 'px';
menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
// Keep it on-screen (mobile): flip above the button if it would overflow the
// bottom, clamp the left edge, and cap the height as a last resort.
const mr = menu.getBoundingClientRect();
if (mr.bottom > window.innerHeight - 6) {
menu.style.top = Math.max(6, (r.top / zs) - mr.height - 4) + 'px';
menu.style.top = Math.max(6, r.top - mr.height - 4) + 'px';
}
if (mr.left < 6) {
menu.style.right = Math.max(6, window.innerWidth - 6 - mr.width) + 'px';

View file

@ -1397,13 +1397,12 @@ function _showThemeZoneHighlight(selector) {
if (el.closest && el.closest('#theme-modal')) return;
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) return;
const zt = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
const overlay = document.createElement('div');
overlay.className = 'theme-zone-highlight';
overlay.style.top = ((r.top / zt) - 2) + 'px';
overlay.style.left = ((r.left / zt) - 2) + 'px';
overlay.style.width = ((r.width / zt) + 4) + 'px';
overlay.style.height = ((r.height / zt) + 4) + 'px';
overlay.style.top = (r.top - 2) + 'px';
overlay.style.left = (r.left - 2) + 'px';
overlay.style.width = (r.width + 4) + 'px';
overlay.style.height = (r.height + 4) + 'px';
document.body.appendChild(overlay);
});
}

View file

@ -157,12 +157,11 @@ export function makeWindowDraggable(modal, options = {}) {
.forEach(a => a.cancel());
} catch (_) {}
const rect = content.getBoundingClientRect();
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
if (onDragStart) {
try { onDragStart({ rect, cx, cy }); } catch (_) {}
}
startX = cx; startY = cy;
startLeft = rect.left / z; startTop = rect.top / z;
startLeft = rect.left; startTop = rect.top;
// Pin position so the drag follows the cursor instead of fighting a
// centering transform / margin. Inline styles win unless CSS uses
// !important (the fullscreen rules do, by design).
@ -215,18 +214,16 @@ export function makeWindowDraggable(modal, options = {}) {
if (rightDock && modal && modal.classList.contains('modal-right-docked')) {
if (rightDock.onMove(cx, cy)) {
const r = content.getBoundingClientRect();
const z2 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
startX = cx; startY = cy;
startLeft = r.left / z2; startTop = r.top / z2;
startLeft = r.left; startTop = r.top;
}
return;
}
if (leftDock && modal && modal.classList.contains('modal-left-docked')) {
if (leftDock.onMove(cx, cy)) {
const r = content.getBoundingClientRect();
const z3 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
startX = cx; startY = cy;
startLeft = r.left / z3; startTop = r.top / z3;
startLeft = r.left; startTop = r.top;
}
return;
}
@ -283,7 +280,7 @@ export function makeWindowDraggable(modal, options = {}) {
header.addEventListener('mousedown', (e) => {
if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return;
if (skipSelector && e.target.closest && e.target.closest(skipSelector)) return;
if (skipSelector && e.target.closest(skipSelector)) return;
e.preventDefault();
movedDuringDrag = false;
_startDrag(e.clientX, e.clientY);
@ -312,7 +309,7 @@ export function makeWindowDraggable(modal, options = {}) {
if (enableTouch) {
header.addEventListener('touchstart', (e) => {
if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return;
if (skipSelector && e.target.closest && e.target.closest(skipSelector)) return;
if (skipSelector && e.target.closest(skipSelector)) return;
const t = e.touches[0];
if (!t) return;
movedDuringDrag = false;

View file

@ -29,14 +29,6 @@ const MIN_H = 200;
// within EDGE px of the window border (close buttons, sliders, inputs, links).
const INTERACTIVE = 'button, input, select, textarea, a, [contenteditable=""], [contenteditable="true"]';
// `zoom` (ui-scale-*) makes window.innerHeight misleading — CSS renders
// content larger than the viewport, so a window saved at 700px pre-zoom
// renders at 875px after 1.25x. Divide constraints by the zoom factor.
function _uiZoom() {
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
return z > 0 ? z : 1;
}
export function makeWindowResizable(content, options = {}) {
if (!content) return;
const modal = options.modal || null;
@ -136,13 +128,10 @@ export function makeWindowResizable(content, options = {}) {
if (width < minW) { if (active.l) left = startRect.left + (startRect.width - minW); width = minW; }
if (height < minH) { if (active.t) top = startRect.top + (startRect.height - minH); height = minH; }
// Keep the window on-screen and never larger than the viewport.
// Zoom-aware: divide by ui-zoom so clamped dimensions fit the visible area.
const z = _uiZoom();
const vhZ = vh / z, vwZ = vw / z;
if (active.l && left < 0) { width += left; left = 0; }
if (active.t && top < 0) { height += top; top = 0; }
if (left + width > vwZ) width = Math.max(minW, vwZ - left);
if (top + height > vhZ) height = Math.max(minH, vhZ - top);
if (left + width > vw) width = Math.max(minW, vw - left);
if (top + height > vh) height = Math.max(minH, vh - top);
content.style.left = left + 'px';
content.style.top = top + 'px';
content.style.width = width + 'px';
@ -231,10 +220,8 @@ export function makeWindowResizable(content, options = {}) {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (saved && saved.w && saved.h) {
const z = _uiZoom();
const vwZ = window.innerWidth / z, vhZ = window.innerHeight / z;
const w = Math.max(minW, Math.min(saved.w, vwZ));
const h = Math.max(minH, Math.min(saved.h, vhZ));
const w = Math.max(minW, Math.min(saved.w, window.innerWidth));
const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
content.style.width = w + 'px';
content.style.height = h + 'px';
content.style.maxWidth = 'none';

View file

@ -177,43 +177,11 @@ html {
(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.
Comprehensive coverage: every modal-content variant, standalone panes,
and user-resized windows that carry inline height styles. Users who
changed zoom/font settings saw headers and close buttons pushed
off-screen because only a subset of windows had the divisor. */
:root.ui-scale-125 .modal-content,
:root.ui-scale-125 .cal-modal-content,
:root.ui-scale-125 .settings-modal-content,
:root.ui-scale-125 .memory-modal-content,
:root.ui-scale-125 .tasks-modal-content,
:root.ui-scale-125 .preset-modal-content,
:root.ui-scale-125 .doclib-modal-content,
:root.ui-scale-125 .gallery-modal-content,
:root.ui-scale-125 .notes-pane,
:root.ui-scale-125 #research-overlay .modal-content,
:root.ui-scale-125 #research-overlay,
:root.ui-scale-125 #compare-model-overlay .modal-content,
:root.ui-scale-125 #compare-model-overlay,
:root.ui-scale-125 #email-lib-modal .modal-content,
:root.ui-scale-125 #doclib-modal .modal-content {
max-height: calc(85dvh / 1.25);
}
/* Any modal-content carrying an inline height style (user-resized) gets
viewport-clamped this catches windows whose saved localStorage size
was set before a zoom change and would otherwise render off-screen. */
:root.ui-scale-125 .modal-content[style*="height"],
:root.ui-scale-125 .cal-modal-content[style*="height"],
:root.ui-scale-125 .settings-modal-content[style*="height"],
:root.ui-scale-125 .memory-modal-content[style*="height"],
:root.ui-scale-125 .tasks-modal-content[style*="height"],
:root.ui-scale-125 .preset-modal-content[style*="height"],
:root.ui-scale-125 .doclib-modal-content[style*="height"],
:root.ui-scale-125 .gallery-modal-content[style*="height"],
:root.ui-scale-125 .notes-pane[style*="height"] {
max-height: calc(100dvh / 1.25 - 16px) !important;
}
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
@ -226,8 +194,6 @@ html {
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); }
/* Theme popup: cap at a reasonable fixed height regardless of zoom */
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
/* ── Background Patterns ── */
@ -3887,7 +3853,6 @@ body.bg-pattern-sparkles {
min-height:80px;
}
.edit-textarea:focus { border-color:var(--red); }
.msg-user.editing { width: 85% !important; }
.edit-save-btn, .edit-cancel-btn {
background:var(--bg); color:var(--fg);
border:1px solid var(--border); border-radius:6px;