Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ded11311e | |||
| 20c7d7e6b7 | |||
| 60452712b2 | |||
| 6c9e04fa69 | |||
| 596d5b7ed2 | |||
| 5d674fe61b | |||
| 33d133b2da | |||
| e19f03fb5d | |||
| 2e06a82cc1 | |||
| 66b45e7d27 | |||
| a9d0df0b50 | |||
| 88e15987d2 | |||
| f0031eac11 | |||
| c93c14ece9 | |||
| 5f13f7b3c9 | |||
| eba94a0de0 | |||
| 4d3cf1a5f9 | |||
| 185667954c | |||
| 23fe1769b7 |
24 changed files with 695 additions and 188 deletions
221
.env.save
Executable file
221
.env.save
Executable file
|
|
@ -0,0 +1,221 @@
|
||||||
|
# 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
|
||||||
|
|
@ -382,7 +382,11 @@ class SessionManager:
|
||||||
else:
|
else:
|
||||||
cached = self.sessions[session_id]
|
cached = self.sessions[session_id]
|
||||||
# Lazy hydrate: metadata-only entries get their messages on first read.
|
# Lazy hydrate: metadata-only entries get their messages on first read.
|
||||||
if not cached.history and getattr(cached, "message_count", 0) > 0:
|
if not cached.history:
|
||||||
|
logger.info(
|
||||||
|
"[ctx-diag] lazy hydrating session=%s (history empty, msg_count=%d)",
|
||||||
|
session_id, getattr(cached, "message_count", 0),
|
||||||
|
)
|
||||||
self._load_session_from_db(session_id)
|
self._load_session_from_db(session_id)
|
||||||
|
|
||||||
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
||||||
|
|
|
||||||
BIN
cuda-keyring_1.1-1_all.deb
Normal file
BIN
cuda-keyring_1.1-1_all.deb
Normal file
Binary file not shown.
|
|
@ -85,8 +85,8 @@ services:
|
||||||
- NVIDIA_VISIBLE_DEVICES=all
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
depends_on:
|
depends_on:
|
||||||
searxng:
|
# searxng:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
chromadb:
|
chromadb:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -109,55 +109,55 @@ services:
|
||||||
- ANONYMIZED_TELEMETRY=FALSE
|
- ANONYMIZED_TELEMETRY=FALSE
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
searxng:
|
# searxng:
|
||||||
# Pinned, not :latest — odysseus waits on searxng's healthcheck
|
# Pinned, not :latest — odysseus waits on searxng's healthcheck
|
||||||
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
||||||
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
||||||
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
||||||
# Bump this deliberately after verifying a newer tag boots clean.
|
# Bump this deliberately after verifying a newer tag boots clean.
|
||||||
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
|
# image: docker.io/searxng/searxng:2026.5.31-7159b8aed
|
||||||
entrypoint:
|
# entrypoint:
|
||||||
- /bin/sh
|
# - /bin/sh
|
||||||
- -c
|
# - -c
|
||||||
- |
|
# - |
|
||||||
set -eu
|
# set -eu
|
||||||
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
|
# 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:-}"
|
# secret=\"$${SEARXNG_SECRET:-}\"
|
||||||
if [ -z "$$secret" ]; then
|
# if [ -z \"$$secret\" ]; then
|
||||||
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
|
# secret=\"$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')\"
|
||||||
fi
|
# fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
# sed \"s|__SEARXNG_SECRET__|$$secret|g\" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
# fi
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
# exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
# ports:
|
||||||
- "127.0.0.1:8080:8080"
|
# - \"127.0.0.1:8080:8080\"
|
||||||
volumes:
|
# volumes:
|
||||||
- searxng-data:/etc/searxng
|
# - searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
# - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
environment:
|
# environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
# - SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
# - SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||||
# The official searxng image runs as the non-root `searxng` user, but its
|
# 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
|
# 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
|
# su-exec, and (with our wrapper above) write settings.yml into the named
|
||||||
# volume. Without these capabilities the wrapper aborts at the redirection
|
# volume. Without these capabilities the wrapper aborts at the redirection
|
||||||
# with EACCES and the container fails its healthcheck with permission
|
# with EACCES and the container fails its healthcheck with permission
|
||||||
# errors during setup. Mirrors the cap set recommended by the upstream
|
# errors during setup. Mirrors the cap set recommended by the upstream
|
||||||
# searxng-docker compose file. See issue #721.
|
# searxng-docker compose file. See issue #721.
|
||||||
cap_drop:
|
# cap_drop:
|
||||||
- ALL
|
# - ALL
|
||||||
cap_add:
|
# cap_add:
|
||||||
- CHOWN
|
# - CHOWN
|
||||||
- SETGID
|
# - SETGID
|
||||||
- SETUID
|
# - SETUID
|
||||||
- DAC_OVERRIDE
|
# - DAC_OVERRIDE
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
|
# test: [\"CMD-SHELL\", \"python -c \\\"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\\\"\"]\"
|
||||||
interval: 5s
|
# interval: 5s
|
||||||
timeout: 6s
|
# timeout: 6s
|
||||||
retries: 20
|
# retries: 20
|
||||||
start_period: 10s
|
# start_period: 10s
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
ntfy:
|
ntfy:
|
||||||
image: docker.io/binwiederhier/ntfy
|
image: docker.io/binwiederhier/ntfy
|
||||||
|
|
@ -171,6 +171,6 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
searxng-data:
|
# searxng-data:
|
||||||
chromadb-data:
|
chromadb-data:
|
||||||
ntfy-cache:
|
ntfy-cache:
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ services:
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
|
- LD_LIBRARY_PATH=/app/.local/bin
|
||||||
- LLM_HOST=${LLM_HOST:-localhost}
|
- LLM_HOST=${LLM_HOST:-localhost}
|
||||||
- LLM_HOSTS=${LLM_HOSTS:-}
|
- LLM_HOSTS=${LLM_HOSTS:-}
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
|
@ -71,11 +72,14 @@ services:
|
||||||
- PUID=${PUID:-1000}
|
- PUID=${PUID:-1000}
|
||||||
- PGID=${PGID:-1000}
|
- PGID=${PGID:-1000}
|
||||||
depends_on:
|
depends_on:
|
||||||
searxng:
|
# searxng:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
chromadb:
|
chromadb:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- searxng-net
|
||||||
|
|
||||||
chromadb:
|
chromadb:
|
||||||
image: docker.io/chromadb/chroma:latest
|
image: docker.io/chromadb/chroma:latest
|
||||||
|
|
@ -87,55 +91,55 @@ services:
|
||||||
- ANONYMIZED_TELEMETRY=FALSE
|
- ANONYMIZED_TELEMETRY=FALSE
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
searxng:
|
# searxng:
|
||||||
# Pinned, not :latest — odysseus waits on searxng's healthcheck
|
# Pinned, not :latest — odysseus waits on searxng's healthcheck
|
||||||
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
# (depends_on: condition: service_healthy), so a broken upstream `latest`
|
||||||
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
# tag blocks the whole app from starting. 2026.6.2 crashes on boot with
|
||||||
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
# `KeyError: 'default_doi_resolver'`, failing the healthcheck (issue #1414).
|
||||||
# Bump this deliberately after verifying a newer tag boots clean.
|
# Bump this deliberately after verifying a newer tag boots clean.
|
||||||
image: docker.io/searxng/searxng:2026.5.31-7159b8aed
|
# image: docker.io/searxng/searxng:2026.5.31-7159b8aed
|
||||||
entrypoint:
|
# entrypoint:
|
||||||
- /bin/sh
|
# - /bin/sh
|
||||||
- -c
|
# - -c
|
||||||
- |
|
# - |
|
||||||
set -eu
|
# set -eu
|
||||||
if [ ! -s /etc/searxng/settings.yml ] || grep -q 'odysseus-local-searxng-json-2026-05-30\|__SEARXNG_SECRET__' /etc/searxng/settings.yml; then
|
# 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:-}"
|
# secret="$${SEARXNG_SECRET:-}"
|
||||||
if [ -z "$$secret" ]; then
|
# if [ -z "$$secret" ]; then
|
||||||
secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
|
# secret="$$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
|
||||||
fi
|
# fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
# sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
# fi
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
# exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
# ports:
|
||||||
- "127.0.0.1:8080:8080"
|
# - "127.0.0.1:8080:8080"
|
||||||
volumes:
|
# volumes:
|
||||||
- searxng-data:/etc/searxng
|
# - searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
# - ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
environment:
|
# environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
# - SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
# restart: unless-stopped
|
||||||
# The official searxng image runs as the non-root `searxng` user, but its
|
# # 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
|
# # 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
|
# # su-exec, and (with our wrapper above) write settings.yml into the named
|
||||||
# volume. Without these capabilities the wrapper aborts at the redirection
|
# # volume. Without these capabilities the wrapper aborts at the redirection
|
||||||
# with EACCES and the container fails its healthcheck with permission
|
# # with EACCES and the container fails its healthcheck with permission
|
||||||
# errors during setup. Mirrors the cap set recommended by the upstream
|
# # errors during setup. Mirrors the cap set recommended by the upstream
|
||||||
# searxng-docker compose file. See issue #721.
|
# # searxng-docker compose file. See issue #721.
|
||||||
cap_drop:
|
# cap_drop:
|
||||||
- ALL
|
# - ALL
|
||||||
cap_add:
|
# cap_add:
|
||||||
- CHOWN
|
# - CHOWN
|
||||||
- SETGID
|
# - SETGID
|
||||||
- SETUID
|
# - SETUID
|
||||||
- DAC_OVERRIDE
|
# - DAC_OVERRIDE
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
|
# test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/', timeout=5).read(1)\""]
|
||||||
interval: 5s
|
# interval: 5s
|
||||||
timeout: 6s
|
# timeout: 6s
|
||||||
retries: 20
|
# retries: 20
|
||||||
start_period: 10s
|
# start_period: 10s
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
ntfy:
|
ntfy:
|
||||||
image: docker.io/binwiederhier/ntfy
|
image: docker.io/binwiederhier/ntfy
|
||||||
|
|
@ -149,6 +153,11 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
searxng-data:
|
# searxng-data:
|
||||||
chromadb-data:
|
chromadb-data:
|
||||||
ntfy-cache:
|
ntfy-cache:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
searxng-net:
|
||||||
|
external: true
|
||||||
|
name: searxng_default
|
||||||
|
|
|
||||||
66
docs/improvement-tasks.md
Normal file
66
docs/improvement-tasks.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# 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)
|
||||||
|
|
@ -751,7 +751,13 @@ async def build_chat_context(
|
||||||
sess.model = norm
|
sess.model = norm
|
||||||
|
|
||||||
# Build messages
|
# Build messages
|
||||||
messages = preface + sess.get_context_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
|
||||||
|
|
||||||
# Current date/time — injected as a standalone *user*-role context message
|
# Current date/time — injected as a standalone *user*-role context message
|
||||||
# placed immediately before the latest user turn, NOT folded into the
|
# placed immediately before the latest user turn, NOT folded into the
|
||||||
|
|
|
||||||
|
|
@ -2696,6 +2696,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||||
elif disk_env.get("servers") and not inc_env.get("servers"):
|
elif disk_env.get("servers") and not inc_env.get("servers"):
|
||||||
inc_env["servers"] = disk_env["servers"]
|
inc_env["servers"] = disk_env["servers"]
|
||||||
logger.warning("cookbook state POST: incoming env.servers empty; preserved on-disk servers (anti-wipe guard)")
|
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)")
|
||||||
|
|
||||||
disk_tasks = on_disk.get("tasks") or [] if isinstance(on_disk, dict) else []
|
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 []
|
incoming_tasks = data.get("tasks") if isinstance(data.get("tasks"), list) else []
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,8 @@ _AGENT_PREAMBLE = """\
|
||||||
You are an AI assistant with tool access. You can run shell commands, execute Python, search the web, \
|
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. \
|
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. \
|
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."""
|
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."""
|
||||||
|
|
||||||
_AGENT_RULES = """\
|
_AGENT_RULES = """\
|
||||||
## Rules
|
## Rules
|
||||||
|
|
@ -179,7 +180,8 @@ _API_AGENT_RULES = """\
|
||||||
|
|
||||||
_AGENT_PREAMBLE = """\
|
_AGENT_PREAMBLE = """\
|
||||||
You are an AI assistant with tool access. Only the tools listed below are available for this turn.
|
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."""
|
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."""
|
||||||
|
|
||||||
_AGENT_RULES = """\
|
_AGENT_RULES = """\
|
||||||
## Base rules
|
## Base rules
|
||||||
|
|
@ -1502,7 +1504,8 @@ def _build_system_prompt(
|
||||||
"knowing the user's personal style."
|
"knowing the user's personal style."
|
||||||
)
|
)
|
||||||
_doc_message = untrusted_context_message("active editor document", doc_ctx)
|
_doc_message = untrusted_context_message("active editor document", doc_ctx)
|
||||||
_doc_message["_protected"] = True
|
if _doc_message:
|
||||||
|
_doc_message["_protected"] = True
|
||||||
|
|
||||||
# Auto-detect suggestion mode
|
# Auto-detect suggestion mode
|
||||||
_last_user_msg = ""
|
_last_user_msg = ""
|
||||||
|
|
@ -1582,7 +1585,8 @@ def _build_system_prompt(
|
||||||
f"open email's sender.\n"
|
f"open email's sender.\n"
|
||||||
)
|
)
|
||||||
_email_message = untrusted_context_message("active email reader", email_ctx)
|
_email_message = untrusted_context_message("active email reader", email_ctx)
|
||||||
_email_message["_protected"] = True
|
if _email_message:
|
||||||
|
_email_message["_protected"] = True
|
||||||
|
|
||||||
# Inject writing style for any email writing path. This is deliberately
|
# Inject writing style for any email writing path. This is deliberately
|
||||||
# broader than read/list: models may compose via send_email, reply_to_email,
|
# broader than read/list: models may compose via send_email, reply_to_email,
|
||||||
|
|
@ -2910,6 +2914,54 @@ async def stream_agent_loop(
|
||||||
r"\b[^.\n]{0,140}",
|
r"\b[^.\n]{0,140}",
|
||||||
re.IGNORECASE,
|
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
|
_awaiting_user = False # set by ask_user → end the turn and wait for a choice
|
||||||
|
|
||||||
# Document streaming state (persists across rounds)
|
# Document streaming state (persists across rounds)
|
||||||
|
|
@ -3431,24 +3483,94 @@ async def stream_agent_loop(
|
||||||
"session_id from the serve/list result. Never answer with "
|
"session_id from the serve/list result. Never answer with "
|
||||||
"\"check logs\" when those tools are available."
|
"\"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({
|
messages.append({
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": (
|
"content": (
|
||||||
f"You just wrote: \"{_matched_phrase}\" — but ended the "
|
f"You just wrote: \"{_matched_phrase}\" — but ended the "
|
||||||
"turn without making the actual tool call. The user can "
|
"turn without making the actual tool call." + _tool_hint +
|
||||||
"see you announced the action but didn't run it, which "
|
" The user can see you announced the action but didn't run "
|
||||||
"is the most frustrating thing you can do. "
|
"it, which is the most frustrating thing you can do. "
|
||||||
"DO IT NOW: emit the actual function call this turn. "
|
"DO IT NOW: emit the function call this turn. "
|
||||||
f"{_cookbook_log_hint}"
|
+ _cookbook_log_hint +
|
||||||
"If you decided not to do it after all, say so plainly in "
|
"If you decided not to do it after all, say so plainly in "
|
||||||
"one sentence instead of restating the plan."
|
"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'
|
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
|
||||||
continue
|
continue
|
||||||
break # no tools — done
|
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) ──────────────
|
# ── Loop-breaker (Terminus-style stall detector) ──────────────
|
||||||
# Stall detector for repeated no-progress tool loops.
|
# Stall detector for repeated no-progress tool loops.
|
||||||
# A round is "useless" ONLY when it re-issues a recent tool call AND
|
# A round is "useless" ONLY when it re-issues a recent tool call AND
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ def _sanitize_label(label: str) -> str:
|
||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
||||||
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any] | None:
|
||||||
"""Return an LLM message that keeps retrieved/source text out of system role.
|
"""Return an LLM message that keeps retrieved/source text out of system role.
|
||||||
|
|
||||||
The template is structured so that *only* the hardcoded
|
The template is structured so that *only* the hardcoded
|
||||||
|
|
@ -69,9 +69,15 @@ def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
|
||||||
caller-derived text is placed in the pre-guard trusted framing zone.
|
caller-derived text is placed in the pre-guard trusted framing zone.
|
||||||
The source label and the body content are both placed *inside* the
|
The source label and the body content are both placed *inside* the
|
||||||
guarded block where the LLM treats them as untrusted data.
|
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)
|
safe_label = _sanitize_label(label)
|
||||||
text = "" if content is None else str(content)
|
|
||||||
text = _escape_guard_markers(text)
|
text = _escape_guard_markers(text)
|
||||||
return {
|
return {
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "create_document",
|
"name": "create_document",
|
||||||
"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.",
|
"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.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -203,7 +203,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "edit_document",
|
"name": "edit_document",
|
||||||
"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.",
|
"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.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -752,15 +752,16 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_documents",
|
"name": "manage_documents",
|
||||||
"description": "Manage documents: list all documents (with optional search/language filter), delete documents, or run tidy cleanup.",
|
"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.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {"type": "string", "enum": ["list", "delete", "tidy"]},
|
"action": {"type": "string", "enum": ["list", "read", "view", "open", "get", "delete", "tidy"]},
|
||||||
"document_id": {"type": "string", "description": "Document ID (for delete)"},
|
"document_id": {"type": "string", "description": "Document ID (for read, delete)"},
|
||||||
"search": {"type": "string", "description": "Search query (for list)"},
|
"search": {"type": "string", "description": "Search query (for list)"},
|
||||||
"language": {"type": "string", "description": "Filter by language (for list)"},
|
"language": {"type": "string", "description": "Filter by language (for list)"},
|
||||||
"limit": {"type": "integer", "description": "Max results (for list, default 50)"}
|
"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)"}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1909,19 +1909,20 @@ function initializeEventListeners() {
|
||||||
// genuinely taller than the room above the button.
|
// genuinely taller than the room above the button.
|
||||||
function positionMenu() {
|
function positionMenu() {
|
||||||
const r = plusBtn.getBoundingClientRect();
|
const r = plusBtn.getBoundingClientRect();
|
||||||
menu.style.left = r.left + 'px';
|
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
|
menu.style.left = (r.left / z) + 'px';
|
||||||
menu.style.right = 'auto';
|
menu.style.right = 'auto';
|
||||||
menu.style.bottom = 'auto';
|
menu.style.bottom = 'auto';
|
||||||
menu.style.maxHeight = ''; // reset so we can measure the natural height
|
menu.style.maxHeight = ''; // reset so we can measure the natural height
|
||||||
menu.style.overflowY = '';
|
menu.style.overflowY = '';
|
||||||
const avail = r.top - 16; // room above the chevron
|
const avail = (r.top / z) - 16; // room above the chevron (viewport-relative)
|
||||||
const natural = menu.scrollHeight;
|
const natural = menu.scrollHeight;
|
||||||
const h = Math.min(natural, avail);
|
const h = Math.min(natural, avail);
|
||||||
if (natural > avail) { // only cap + scroll when it doesn't fit
|
if (natural > avail) { // only cap + scroll when it doesn't fit
|
||||||
menu.style.maxHeight = avail + 'px';
|
menu.style.maxHeight = avail + 'px';
|
||||||
menu.style.overflowY = 'auto';
|
menu.style.overflowY = 'auto';
|
||||||
}
|
}
|
||||||
menu.style.top = (r.top - 8 - h) + 'px';
|
menu.style.top = ((r.top / z) - 8 - h) + 'px';
|
||||||
}
|
}
|
||||||
// Tapping the chevron must NOT steal focus from the message box, or the
|
// Tapping the chevron must NOT steal focus from the message box, or the
|
||||||
// mobile keyboard collapses. preventDefault on pointerdown keeps the
|
// mobile keyboard collapses. preventDefault on pointerdown keeps the
|
||||||
|
|
|
||||||
|
|
@ -4018,10 +4018,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
bodyEl.innerHTML = '';
|
bodyEl.innerHTML = '';
|
||||||
bodyEl.appendChild(editor);
|
bodyEl.appendChild(editor);
|
||||||
bodyEl.appendChild(btnRow);
|
bodyEl.appendChild(btnRow);
|
||||||
|
userMsgElement.classList.add('editing');
|
||||||
editor.focus();
|
editor.focus();
|
||||||
|
|
||||||
cancelBtn.addEventListener('click', (e) => {
|
cancelBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
userMsgElement.classList.remove('editing');
|
||||||
bodyEl.innerHTML = originalHTML;
|
bodyEl.innerHTML = originalHTML;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4054,6 +4056,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Edit failed:', err);
|
console.error('Edit failed:', err);
|
||||||
if (uiModule) uiModule.showError('Edit failed: ' + err.message);
|
if (uiModule) uiModule.showError('Edit failed: ' + err.message);
|
||||||
|
userMsgElement.classList.remove('editing');
|
||||||
bodyEl.innerHTML = originalHTML;
|
bodyEl.innerHTML = originalHTML;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -293,10 +293,11 @@ function commitCurrent() {
|
||||||
function position(p, anchor) {
|
function position(p, anchor) {
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const pRect = p.getBoundingClientRect();
|
const pRect = p.getBoundingClientRect();
|
||||||
let left = rect.left;
|
const zc = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
let top = rect.bottom + 6;
|
let left = rect.left / zc;
|
||||||
|
let top = (rect.bottom / zc) + 6;
|
||||||
if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8;
|
if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8;
|
||||||
if (top + pRect.height > window.innerHeight - 8) top = rect.top - pRect.height - 6;
|
if (top + pRect.height > window.innerHeight - 8) top = (rect.top / zc) - pRect.height - 6;
|
||||||
if (left < 8) left = 8;
|
if (left < 8) left = 8;
|
||||||
if (top < 8) top = 8;
|
if (top < 8) top = 8;
|
||||||
p.style.left = left + 'px';
|
p.style.left = left + 'px';
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,9 @@ export function _parseServePhase(snapshot) {
|
||||||
if (/Ollama API ready on port\s+\d+/i.test(flat)) {
|
if (/Ollama API ready on port\s+\d+/i.test(flat)) {
|
||||||
return { phase: 'ready', status: 'ready' };
|
return { phase: 'ready', status: 'ready' };
|
||||||
}
|
}
|
||||||
|
if (/(?:server is |^|\s)listening on http:\/\//i.test(flat)) {
|
||||||
|
return { phase: 'ready', status: 'ready' };
|
||||||
|
}
|
||||||
const llamaBuildMatches = [...flat.matchAll(/\[\s*(\d{1,3})%\]\s*(?:Building|Linking)/gi)];
|
const llamaBuildMatches = [...flat.matchAll(/\[\s*(\d{1,3})%\]\s*(?:Building|Linking)/gi)];
|
||||||
if (llamaBuildMatches.length) {
|
if (llamaBuildMatches.length) {
|
||||||
const pct = Math.min(100, parseInt(llamaBuildMatches[llamaBuildMatches.length - 1][1], 10));
|
const pct = Math.min(100, parseInt(llamaBuildMatches[llamaBuildMatches.length - 1][1], 10));
|
||||||
|
|
@ -2525,9 +2528,10 @@ export function _renderRunningTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = menuBtn.getBoundingClientRect();
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
|
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
dropdown.style.position = 'fixed';
|
dropdown.style.position = 'fixed';
|
||||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
dropdown.style.top = (rect.bottom / z) + 2 + 'px';
|
||||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
dropdown.style.right = (window.innerWidth - (rect.right / z)) + 'px';
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
// Clamp into the *visible* area. On mobile (esp. Firefox) window.innerHeight
|
// Clamp into the *visible* area. On mobile (esp. Firefox) window.innerHeight
|
||||||
// includes the strip hidden under the dynamic toolbar, so a menu that "fits"
|
// includes the strip hidden under the dynamic toolbar, so a menu that "fits"
|
||||||
|
|
@ -2540,9 +2544,9 @@ export function _renderRunningTab() {
|
||||||
const viewBottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
|
const viewBottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
|
||||||
const dh = dropdown.offsetHeight;
|
const dh = dropdown.offsetHeight;
|
||||||
const m = 8;
|
const m = 8;
|
||||||
let top = rect.bottom + 2;
|
let top = rect.bottom / z + 2;
|
||||||
if (top + dh > viewBottom - m) {
|
if (top + dh > viewBottom - m) {
|
||||||
const above = rect.top - 2 - dh;
|
const above = rect.top / z - 2 - dh;
|
||||||
top = above >= viewTop + m ? above : Math.max(viewTop + m, viewBottom - dh - m);
|
top = above >= viewTop + m ? above : Math.max(viewTop + m, viewBottom - dh - m);
|
||||||
}
|
}
|
||||||
dropdown.style.top = top + 'px';
|
dropdown.style.top = top + 'px';
|
||||||
|
|
|
||||||
|
|
@ -1077,7 +1077,8 @@ function _rerenderCachedModels() {
|
||||||
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
||||||
dropdown.appendChild(cancelDiv);
|
dropdown.appendChild(cancelDiv);
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
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;`;
|
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;`;
|
||||||
document.body.appendChild(dropdown);
|
document.body.appendChild(dropdown);
|
||||||
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
|
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
|
||||||
// on mobile under the dynamic toolbar). Flip above the button if there's no
|
// on mobile under the dynamic toolbar). Flip above the button if there's no
|
||||||
|
|
@ -1089,9 +1090,9 @@ function _rerenderCachedModels() {
|
||||||
const viewBottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
|
const viewBottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
|
||||||
const dh = dropdown.offsetHeight;
|
const dh = dropdown.offsetHeight;
|
||||||
const mm = 8;
|
const mm = 8;
|
||||||
let top = rect.bottom + 2;
|
let top = rect.bottom / z + 2;
|
||||||
if (top + dh > viewBottom - mm) {
|
if (top + dh > viewBottom - mm) {
|
||||||
const above = rect.top - 2 - dh;
|
const above = rect.top / z - 2 - dh;
|
||||||
top = above >= viewTop + mm ? above : Math.max(viewTop + mm, viewBottom - dh - mm);
|
top = above >= viewTop + mm ? above : Math.max(viewTop + mm, viewBottom - dh - mm);
|
||||||
}
|
}
|
||||||
dropdown.style.top = top + 'px';
|
dropdown.style.top = top + 'px';
|
||||||
|
|
@ -2327,10 +2328,11 @@ function _rerenderCachedModels() {
|
||||||
// Clamp into the viewport using the menu's real size (both axes); flip
|
// 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.
|
// above the toggle if there isn't room below. Right-align to the anchor.
|
||||||
const w = dropdown.offsetWidth, h = dropdown.offsetHeight;
|
const w = dropdown.offsetWidth, h = dropdown.offsetHeight;
|
||||||
let left = Math.min(rect.right - w, window.innerWidth - w - 8);
|
const za = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
|
let left = Math.min((rect.right / za) - w, window.innerWidth - w - 8);
|
||||||
left = Math.max(8, left);
|
left = Math.max(8, left);
|
||||||
let top = rect.bottom + 6;
|
let top = (rect.bottom / za) + 6;
|
||||||
if (top + h > window.innerHeight - 8) top = Math.max(8, rect.top - 6 - h);
|
if (top + h > window.innerHeight - 8) top = Math.max(8, (rect.top / za) - 6 - h);
|
||||||
dropdown.style.left = `${left}px`;
|
dropdown.style.left = `${left}px`;
|
||||||
dropdown.style.top = `${top}px`;
|
dropdown.style.top = `${top}px`;
|
||||||
dropdown.style.visibility = '';
|
dropdown.style.visibility = '';
|
||||||
|
|
@ -2432,8 +2434,9 @@ function _rerenderCachedModels() {
|
||||||
menu.appendChild(mk('Clear Server', 'cookbook-dropdown-danger', () => _clearBtn?.click()));
|
menu.appendChild(mk('Clear Server', 'cookbook-dropdown-danger', () => _clearBtn?.click()));
|
||||||
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
||||||
const r = _launchMoreBtn.getBoundingClientRect();
|
const r = _launchMoreBtn.getBoundingClientRect();
|
||||||
|
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
menu.style.position = 'fixed';
|
menu.style.position = 'fixed';
|
||||||
menu.style.right = (window.innerWidth - r.right) + 'px';
|
menu.style.right = (window.innerWidth - (r.right / z)) + 'px';
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
{
|
{
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
|
|
@ -2478,8 +2481,9 @@ function _rerenderCachedModels() {
|
||||||
menu.appendChild(mk('Probe GPUs', '', () => _probeBtn?.click()));
|
menu.appendChild(mk('Probe GPUs', '', () => _probeBtn?.click()));
|
||||||
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
||||||
const r = _splitArrow.getBoundingClientRect();
|
const r = _splitArrow.getBoundingClientRect();
|
||||||
|
const z2 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
menu.style.position = 'fixed';
|
menu.style.position = 'fixed';
|
||||||
menu.style.right = (window.innerWidth - r.right) + 'px';
|
menu.style.right = (window.innerWidth - (r.right / z2)) + 'px';
|
||||||
document.body.appendChild(menu);
|
document.body.appendChild(menu);
|
||||||
// Default open BELOW, but if there's no room (esp. on mobile where
|
// 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
|
// the arrow sits near the bottom of the modal) flip ABOVE so the
|
||||||
|
|
@ -2586,12 +2590,13 @@ function _rerenderCachedModels() {
|
||||||
// popup stays fully visible — GPU buttons near the right edge
|
// popup stays fully visible — GPU buttons near the right edge
|
||||||
// of the modal previously anchored the popup mostly off-screen.
|
// of the modal previously anchored the popup mostly off-screen.
|
||||||
const r = anchorBtn.getBoundingClientRect();
|
const r = anchorBtn.getBoundingClientRect();
|
||||||
|
const z3 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
const vw = window.innerWidth || document.documentElement.clientWidth;
|
const vw = window.innerWidth || document.documentElement.clientWidth;
|
||||||
const vh = window.innerHeight || document.documentElement.clientHeight;
|
const vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
const pw = popup.offsetWidth || 320;
|
const pw = popup.offsetWidth || 320;
|
||||||
const ph = popup.offsetHeight || 200;
|
const ph = popup.offsetHeight || 200;
|
||||||
let left = r.left;
|
let left = r.left / z3;
|
||||||
let top = r.bottom + 4;
|
let top = (r.bottom / z3) + 4;
|
||||||
// Push left so the popup doesn't overflow the right edge.
|
// Push left so the popup doesn't overflow the right edge.
|
||||||
if (left + pw > vw - 8) left = Math.max(8, vw - pw - 8);
|
if (left + pw > vw - 8) left = Math.max(8, vw - pw - 8);
|
||||||
// If there isn't room below, render above the button instead.
|
// If there isn't room below, render above the button instead.
|
||||||
|
|
|
||||||
|
|
@ -667,6 +667,20 @@ 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() {
|
async function _openExportPdfModal() {
|
||||||
if (!activeDocId) return;
|
if (!activeDocId) return;
|
||||||
await _saveActiveDocBeforeExport();
|
await _saveActiveDocBeforeExport();
|
||||||
|
|
@ -4596,6 +4610,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
pinned to the bottom no matter which pane (editor / md-preview /
|
pinned to the bottom no matter which pane (editor / md-preview /
|
||||||
csv / html / pdf) is the one growing to fill. -->
|
csv / html / pdf) is the one growing to fill. -->
|
||||||
<div id="doc-actions-footer" class="doc-email-actions">
|
<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">
|
<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-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>
|
<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>
|
||||||
|
|
@ -4799,6 +4814,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
else saveDocument({ silent: false, forceVersion: true });
|
else saveDocument({ silent: false, forceVersion: true });
|
||||||
});
|
});
|
||||||
document.getElementById('doc-footer-export-btn')?.addEventListener('click', (e) => showExportMenu(null, e.currentTarget.getBoundingClientRect()));
|
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
|
// Mobile footer: Close the current doc + Copy its content (replaces the
|
||||||
// per-tab × on small screens, mirroring the email reader's Close footer).
|
// per-tab × on small screens, mirroring the email reader's Close footer).
|
||||||
document.getElementById('doc-mobile-close')?.addEventListener('click', () => { if (activeDocId) closeTab(activeDocId); });
|
document.getElementById('doc-mobile-close')?.addEventListener('click', () => { if (activeDocId) closeTab(activeDocId); });
|
||||||
|
|
@ -9990,24 +10006,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _handleMarkdownPreviewClickHint() {
|
function _handleMarkdownPreviewClickHint() {
|
||||||
if (!_isMarkdownPreviewVisible()) return;
|
// Intentionally removed — click-to-highlight while reading is normal behavior
|
||||||
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) {
|
function _refreshMarkdownPreviewIfVisible(docId, content) {
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,10 @@ function togglePicker(anchor, target) {
|
||||||
document.body.appendChild(_pickerEl);
|
document.body.appendChild(_pickerEl);
|
||||||
|
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const ze = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
_pickerEl.style.position = 'fixed';
|
_pickerEl.style.position = 'fixed';
|
||||||
_pickerEl.style.top = (rect.bottom + 4) + 'px';
|
_pickerEl.style.top = ((rect.bottom / ze) + 4) + 'px';
|
||||||
_pickerEl.style.left = rect.left + 'px';
|
_pickerEl.style.left = (rect.left / ze) + 'px';
|
||||||
_pickerEl.style.zIndex = String(topPortalZ());
|
_pickerEl.style.zIndex = String(topPortalZ());
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
|
||||||
|
|
@ -825,17 +825,18 @@ function createSessionItem(s) {
|
||||||
} else {
|
} else {
|
||||||
// Position the dropdown using viewport coords
|
// Position the dropdown using viewport coords
|
||||||
const rect = menuBtn.getBoundingClientRect();
|
const rect = menuBtn.getBoundingClientRect();
|
||||||
|
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
dropdown.style.left = '';
|
dropdown.style.left = '';
|
||||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
dropdown.style.right = (window.innerWidth - rect.right / z) + 'px';
|
||||||
// Show off-screen first to measure height
|
// Show off-screen first to measure height
|
||||||
dropdown.style.top = '-9999px';
|
dropdown.style.top = '-9999px';
|
||||||
dropdown.style.display = 'block';
|
dropdown.style.display = 'block';
|
||||||
const ddRect = dropdown.getBoundingClientRect();
|
const ddRect = dropdown.getBoundingClientRect();
|
||||||
// Flip above if not enough room below
|
// Flip above if not enough room below
|
||||||
if (rect.bottom + 2 + ddRect.height > window.innerHeight) {
|
if (rect.bottom / z + 2 + ddRect.height / z > window.innerHeight) {
|
||||||
dropdown.style.top = Math.max(2, rect.top - ddRect.height - 2) + 'px';
|
dropdown.style.top = Math.max(2, rect.top / z - ddRect.height / z - 2) + 'px';
|
||||||
} else {
|
} else {
|
||||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
dropdown.style.top = rect.bottom / z + 2 + 'px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -2669,14 +2670,15 @@ function _showDropdown(anchorEl, items) {
|
||||||
|
|
||||||
// Position using viewport coords (same pattern as session menus)
|
// Position using viewport coords (same pattern as session menus)
|
||||||
const rect = anchorEl.getBoundingClientRect();
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
dd.style.right = (window.innerWidth - rect.right) + 'px';
|
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
|
dd.style.right = (window.innerWidth - rect.right / z) + 'px';
|
||||||
dd.style.top = '-9999px';
|
dd.style.top = '-9999px';
|
||||||
dd.style.display = 'block';
|
dd.style.display = 'block';
|
||||||
const ddRect = dd.getBoundingClientRect();
|
const ddRect = dd.getBoundingClientRect();
|
||||||
if (rect.bottom + 2 + ddRect.height > window.innerHeight) {
|
if (rect.bottom / z + 2 + ddRect.height / z > window.innerHeight) {
|
||||||
dd.style.top = Math.max(2, rect.top - ddRect.height - 2) + 'px';
|
dd.style.top = Math.max(2, rect.top / z - ddRect.height / z - 2) + 'px';
|
||||||
} else {
|
} else {
|
||||||
dd.style.top = (rect.bottom + 2) + 'px';
|
dd.style.top = (rect.bottom / z + 2) + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() { dd.remove(); }
|
function close() { dd.remove(); }
|
||||||
|
|
|
||||||
|
|
@ -443,13 +443,14 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||||
// bring-to-front counter climbs past the static value (#4720).
|
// bring-to-front counter climbs past the static value (#4720).
|
||||||
menu.style.zIndex = String(topPortalZ());
|
menu.style.zIndex = String(topPortalZ());
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
menu.style.top = (r.bottom + 4) + 'px';
|
const zs = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
|
menu.style.top = ((r.bottom / zs) + 4) + 'px';
|
||||||
|
menu.style.right = Math.max(6, window.innerWidth - (r.right / zs)) + 'px';
|
||||||
// Keep it on-screen (mobile): flip above the button if it would overflow the
|
// 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.
|
// bottom, clamp the left edge, and cap the height as a last resort.
|
||||||
const mr = menu.getBoundingClientRect();
|
const mr = menu.getBoundingClientRect();
|
||||||
if (mr.bottom > window.innerHeight - 6) {
|
if (mr.bottom > window.innerHeight - 6) {
|
||||||
menu.style.top = Math.max(6, r.top - mr.height - 4) + 'px';
|
menu.style.top = Math.max(6, (r.top / zs) - mr.height - 4) + 'px';
|
||||||
}
|
}
|
||||||
if (mr.left < 6) {
|
if (mr.left < 6) {
|
||||||
menu.style.right = Math.max(6, window.innerWidth - 6 - mr.width) + 'px';
|
menu.style.right = Math.max(6, window.innerWidth - 6 - mr.width) + 'px';
|
||||||
|
|
|
||||||
|
|
@ -1397,12 +1397,13 @@ function _showThemeZoneHighlight(selector) {
|
||||||
if (el.closest && el.closest('#theme-modal')) return;
|
if (el.closest && el.closest('#theme-modal')) return;
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
if (r.width < 2 || r.height < 2) return;
|
if (r.width < 2 || r.height < 2) return;
|
||||||
|
const zt = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'theme-zone-highlight';
|
overlay.className = 'theme-zone-highlight';
|
||||||
overlay.style.top = (r.top - 2) + 'px';
|
overlay.style.top = ((r.top / zt) - 2) + 'px';
|
||||||
overlay.style.left = (r.left - 2) + 'px';
|
overlay.style.left = ((r.left / zt) - 2) + 'px';
|
||||||
overlay.style.width = (r.width + 4) + 'px';
|
overlay.style.width = ((r.width / zt) + 4) + 'px';
|
||||||
overlay.style.height = (r.height + 4) + 'px';
|
overlay.style.height = ((r.height / zt) + 4) + 'px';
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,11 +157,12 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||||
.forEach(a => a.cancel());
|
.forEach(a => a.cancel());
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
const rect = content.getBoundingClientRect();
|
const rect = content.getBoundingClientRect();
|
||||||
|
const z = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
if (onDragStart) {
|
if (onDragStart) {
|
||||||
try { onDragStart({ rect, cx, cy }); } catch (_) {}
|
try { onDragStart({ rect, cx, cy }); } catch (_) {}
|
||||||
}
|
}
|
||||||
startX = cx; startY = cy;
|
startX = cx; startY = cy;
|
||||||
startLeft = rect.left; startTop = rect.top;
|
startLeft = rect.left / z; startTop = rect.top / z;
|
||||||
// Pin position so the drag follows the cursor instead of fighting a
|
// Pin position so the drag follows the cursor instead of fighting a
|
||||||
// centering transform / margin. Inline styles win unless CSS uses
|
// centering transform / margin. Inline styles win unless CSS uses
|
||||||
// !important (the fullscreen rules do, by design).
|
// !important (the fullscreen rules do, by design).
|
||||||
|
|
@ -214,16 +215,18 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||||
if (rightDock && modal && modal.classList.contains('modal-right-docked')) {
|
if (rightDock && modal && modal.classList.contains('modal-right-docked')) {
|
||||||
if (rightDock.onMove(cx, cy)) {
|
if (rightDock.onMove(cx, cy)) {
|
||||||
const r = content.getBoundingClientRect();
|
const r = content.getBoundingClientRect();
|
||||||
|
const z2 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
startX = cx; startY = cy;
|
startX = cx; startY = cy;
|
||||||
startLeft = r.left; startTop = r.top;
|
startLeft = r.left / z2; startTop = r.top / z2;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (leftDock && modal && modal.classList.contains('modal-left-docked')) {
|
if (leftDock && modal && modal.classList.contains('modal-left-docked')) {
|
||||||
if (leftDock.onMove(cx, cy)) {
|
if (leftDock.onMove(cx, cy)) {
|
||||||
const r = content.getBoundingClientRect();
|
const r = content.getBoundingClientRect();
|
||||||
|
const z3 = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
|
||||||
startX = cx; startY = cy;
|
startX = cx; startY = cy;
|
||||||
startLeft = r.left; startTop = r.top;
|
startLeft = r.left / z3; startTop = r.top / z3;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -280,7 +283,7 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||||
|
|
||||||
header.addEventListener('mousedown', (e) => {
|
header.addEventListener('mousedown', (e) => {
|
||||||
if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return;
|
if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return;
|
||||||
if (skipSelector && e.target.closest(skipSelector)) return;
|
if (skipSelector && e.target.closest && e.target.closest(skipSelector)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
movedDuringDrag = false;
|
movedDuringDrag = false;
|
||||||
_startDrag(e.clientX, e.clientY);
|
_startDrag(e.clientX, e.clientY);
|
||||||
|
|
@ -309,7 +312,7 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||||
if (enableTouch) {
|
if (enableTouch) {
|
||||||
header.addEventListener('touchstart', (e) => {
|
header.addEventListener('touchstart', (e) => {
|
||||||
if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return;
|
if (mobileSkip > 0 && window.innerWidth <= mobileSkip) return;
|
||||||
if (skipSelector && e.target.closest(skipSelector)) return;
|
if (skipSelector && e.target.closest && e.target.closest(skipSelector)) return;
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
movedDuringDrag = false;
|
movedDuringDrag = false;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ const MIN_H = 200;
|
||||||
// within EDGE px of the window border (close buttons, sliders, inputs, links).
|
// within EDGE px of the window border (close buttons, sliders, inputs, links).
|
||||||
const INTERACTIVE = 'button, input, select, textarea, a, [contenteditable=""], [contenteditable="true"]';
|
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 = {}) {
|
export function makeWindowResizable(content, options = {}) {
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
const modal = options.modal || null;
|
const modal = options.modal || null;
|
||||||
|
|
@ -128,10 +136,13 @@ export function makeWindowResizable(content, options = {}) {
|
||||||
if (width < minW) { if (active.l) left = startRect.left + (startRect.width - minW); width = minW; }
|
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; }
|
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.
|
// 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.l && left < 0) { width += left; left = 0; }
|
||||||
if (active.t && top < 0) { height += top; top = 0; }
|
if (active.t && top < 0) { height += top; top = 0; }
|
||||||
if (left + width > vw) width = Math.max(minW, vw - left);
|
if (left + width > vwZ) width = Math.max(minW, vwZ - left);
|
||||||
if (top + height > vh) height = Math.max(minH, vh - top);
|
if (top + height > vhZ) height = Math.max(minH, vhZ - top);
|
||||||
content.style.left = left + 'px';
|
content.style.left = left + 'px';
|
||||||
content.style.top = top + 'px';
|
content.style.top = top + 'px';
|
||||||
content.style.width = width + 'px';
|
content.style.width = width + 'px';
|
||||||
|
|
@ -220,8 +231,10 @@ export function makeWindowResizable(content, options = {}) {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
|
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
|
||||||
if (saved && saved.w && saved.h) {
|
if (saved && saved.w && saved.h) {
|
||||||
const w = Math.max(minW, Math.min(saved.w, window.innerWidth));
|
const z = _uiZoom();
|
||||||
const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
|
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));
|
||||||
content.style.width = w + 'px';
|
content.style.width = w + 'px';
|
||||||
content.style.height = h + 'px';
|
content.style.height = h + 'px';
|
||||||
content.style.maxWidth = 'none';
|
content.style.maxWidth = 'none';
|
||||||
|
|
|
||||||
|
|
@ -177,11 +177,43 @@ html {
|
||||||
(a catch-22 — you can't reach the control to turn the size back down). Divide
|
(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.
|
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
|
Desktop only — the mobile `!important` full-sheet rules win on small screens
|
||||||
and stay top-anchored, so their headers are already visible. */
|
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); }
|
Comprehensive coverage: every modal-content variant, standalone panes,
|
||||||
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
|
and user-resized windows that carry inline height styles. Users who
|
||||||
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
|
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;
|
||||||
|
}
|
||||||
/* Cookbook is the one modal that set its height inline (94vh), which beat the
|
/* 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
|
.modal-content compensation above and overflowed the viewport at 1.25x
|
||||||
(header + close button pushed off-screen). Own its height here so the same
|
(header + close button pushed off-screen). Own its height here so the same
|
||||||
|
|
@ -194,6 +226,8 @@ html {
|
||||||
1.25x compensation applies. */
|
1.25x compensation applies. */
|
||||||
.pdf-export-overlay .modal-content { max-height: 86vh; }
|
.pdf-export-overlay .modal-content { max-height: 86vh; }
|
||||||
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
|
: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 ── */
|
/* ── Background Patterns ── */
|
||||||
|
|
||||||
|
|
@ -3853,6 +3887,7 @@ body.bg-pattern-sparkles {
|
||||||
min-height:80px;
|
min-height:80px;
|
||||||
}
|
}
|
||||||
.edit-textarea:focus { border-color:var(--red); }
|
.edit-textarea:focus { border-color:var(--red); }
|
||||||
|
.msg-user.editing { width: 85% !important; }
|
||||||
.edit-save-btn, .edit-cancel-btn {
|
.edit-save-btn, .edit-cancel-btn {
|
||||||
background:var(--bg); color:var(--fg);
|
background:var(--bg); color:var(--fg);
|
||||||
border:1px solid var(--border); border-radius:6px;
|
border:1px solid var(--border); border-radius:6px;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue