Compare commits

..

19 commits
main ... dev

Author SHA1 Message Date
9ded11311e debug: add ctx-diag logging to trace session hydration and message assembly
Some checks failed
ci / docker publish / build (amd64) (push) Has been cancelled
ci / docker publish / build (arm64) (push) Has been cancelled
ci / docker publish / merge manifest + tag (push) Has been cancelled
2026-07-07 22:53:21 -04:00
20c7d7e6b7 fix: unconditional lazy hydration in get_session
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Drop the message_count > 0 guard — if it's 0 in the DB (stale metadata,
race condition, failed persist), the session stays empty and the model
gets no conversation history. The UI history endpoint has its own DB
fallback, so messages display correctly but never reach the LLM.

Now hydrate from DB whenever history is empty, regardless of message_count.
Trade-off: one extra DB query for genuinely empty sessions (rare).
2026-07-07 22:12:22 -04:00
60452712b2 fix: skip empty untrusted context blocks to prevent model hallucination
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
untrusted_context_message() now returns None when content is empty or
whitespace-only. Previously an empty document would produce a blank
UNTRUSTED_SOURCE_DATA wrapper that confused Gemma into thinking the
user pasted a placeholder template.

Callers already use truthiness guards (if _doc_message:) for insertion,
so None propagates cleanly. Two _protected setter sites needed explicit
guards added (doc + email paths in agent_loop.py).
2026-07-07 21:38:15 -04:00
6c9e04fa69 fix: zoom-aware clamping in cookbook task/serve dropdown positioning
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
The initial positioning used zoom-divided rect coordinates but the
visualViewport clamping block used raw rect.bottom/rect.top without
dividing by zoom. At ui-scale-125 the clamp pushed dropdowns off-screen.
Affected cookbook task menu-btn and serve-config dropdown menus.
2026-07-07 16:26:27 -04:00
596d5b7ed2 feat: tool-aware intent supervisor + wrong-tool detector for agent loop
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Intent-without-action supervisor now references specific tool names
from verb→tool mapping when nudging the model ('DO IT NOW: call
list_vault' instead of just 'DO IT NOW'). Also extracts explicitly
named tools from the model's prose.

New wrong-tool detector fires when model calls tool X but prose
describes wanting Y (e.g. rename_session vs list_vault). Injects
correction nudge and skips execution of the wrong call.
2026-07-07 15:54:14 -04:00
5d674fe61b fix: zoom-aware dropdown positioning in session list + archive/research menus
getBoundingClientRect() returns zoom-scaled coords, but
window.innerWidth is unscaled. rect.right/z computed offset was
wildly negative at ui-scale-125, pushing dropdowns off-screen left.
Both createSessionItem() dropdown (rename/delete/archive) and
_showDropdown() (archive/research library cards) affected.
2026-07-07 15:12:59 -04:00
33d133b2da fix: add anti-hallucination rule to agent system prompt
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Both preambles now include a CRITICAL line warning that describing
a tool call in prose without the fenced block does nothing. Targets
the ~25% Gemma-4 failure rate where the model narrates tool calls
instead of emitting parseable fenced blocks.
2026-07-07 14:36:55 -04:00
e19f03fb5d fix: zoom-aware drag positioning + Text node safety in windowDrag
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
_startDrag(): divide getBoundingClientRect().left/.top by zoom factor
(right/left dock paths too). Also guard e.target.closest() calls
against Text nodes that lack the method — prevents close/minimize
buttons from failing the skipSelector check and triggering a drag
on click.
2026-07-07 14:23:46 -04:00
2e06a82cc1 docs: flesh out deep research task — UI button + agent MCP tool for report reading 2026-07-07 14:09:48 -04:00
66b45e7d27 fix: document Chat button now sends title+reference only (no content snippet)
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Removed the 120-char snippet preview from the _sendToChat() reference.
The injected text is now clean: [Title](#document-<id>). Also captured
Deep Research 'Send to Chat' reference injection feature to task board.
2026-07-07 14:00:30 -04:00
a9d0df0b50 fix: remove 'Preview is read-only' click hint toast
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
_handleMarkdownPreviewClickHint() now no-ops. The toast appeared after
3 rapid clicks in the markdown preview and told the user to click Write to
edit — annoying for users who click-drag to highlight while reading. Users
self-hosting Odysseus know how preview/write modes work.
2026-07-07 13:31:40 -04:00
88e15987d2 docs: capture edit-message width fix in completed tasks 2026-07-07 12:53:38 -04:00
f0031eac11 fix: edit-message textarea now fills full message width
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
.msg-user has width:fit-content which collapses when body content is replaced
by a textarea. Added .msg-user.editing CSS rule with width:85% !important,
and JS adds/removes 'editing' class during edit flow.
2026-07-07 12:53:03 -04:00
c93c14ece9 fix: zoom-aware getBoundingClientRect positioning for all dropdowns, menus, pickers, and overlays
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
app.js: overflow plus-menu positionMenu() — divide r.left/r.top by zoom
cookbookRunning.js: hamburger dropdown — use window.innerWidth - (rect.right/z)
cookbookServe.js: 5 fixes — toolbar dropdown, launch-more, split-arrow, GPU popup, saved-menu
skills.js: kebab tool menu — divide rect bounds by zoom
colorPicker.js: picker position() — divide rect coords by zoom
emojiPicker.js: picker open — divide rect coords by zoom
theme.js: zone highlights — divide all rect values by zoom
2026-07-07 12:21:41 -04:00
5f13f7b3c9 feat: 'Send to Chat' button in document editor
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
Adds a 'Chat' button to the left of the Save button in the document
editor footer. Clicking it injects a reference into the chat textarea:
[Title](#document-<id>) — <120-char snippet>...

The reference is not the full document content — just a title link
with opening snippet. The user can then send it or edit the message
before sending. This is the first step toward making documents feel
like first-class citizens in agent chat.
2026-07-07 11:49:24 -04:00
eba94a0de0 fix: add 'read' to manage_documents tool schema + DOCUMENT PANEL disambiguation
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
manage_documents: Added read/view/open/get to the action enum (was only
list/delete/tidy). The handler already supported these actions but the
function-calling schema blocked the model from using them. Added
document_id (for read), offset (pagination), and limit (chars to read)
params with descriptions for the read action.

create_document, edit_document: Added 'DOCUMENT PANEL (not disk)' prefix
to prevent models from using these for filesystem operations. Explicitly
references write_file/edit_file as the disk alternative.

These were the root cause of agent-mode document confusion — the model
had filesystem tools but no way to read library documents.
2026-07-07 11:45:05 -04:00
4d3cf1a5f9 fix: zoom-aware dropdown positioning in cookbook hamburger menus
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
getBoundingClientRect() returns zoom-scaled coordinates but
window.innerWidth does not. With ui-scale-125 active, the right-edge
calculation (window.innerWidth - rect.right) mixed coordinate spaces,
causing dropdowns to appear displaced from their hamburger buttons.

Divide window.innerWidth by the computed zoom factor to match the
rect coordinate space. Fixes both cookbookRunning.js (Active tab)
and cookbookServe.js (Serve tab).
2026-07-07 11:30:22 -04:00
185667954c fix: zoom-aware window constraints — prevent modals overflowing viewport after ui-scale change
Some checks are pending
ci / docker publish / build (amd64) (push) Waiting to run
ci / docker publish / build (arm64) (push) Waiting to run
ci / docker publish / merge manifest + tag (push) Blocked by required conditions
CSS: added comprehensive zoom compensation for ALL modal-content
variants (memory, tasks, preset, doclib, gallery, notes-pane,
research, compare, email-lib) and a safety net for user-resized
windows carrying inline height styles.

JS (windowResize.js): _uiZoom() helper reads computed zoom from :root.
Restore path divides saved localStorage dimensions by zoom factor.
Resize-move viewport clamps use zoom-adjusted vh/vw so dragging
never pushes the window beyond the visible area.

Also added docs/improvement-tasks.md as the Ulysses task board.
2026-07-07 11:16:26 -04:00
23fe1769b7 ulysses: remove bundled searxng — conflicts with standalone instance 2026-07-06 21:25:18 -04:00
23 changed files with 636 additions and 145 deletions

221
.env.save Executable file
View 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

View file

@ -382,7 +382,11 @@ class SessionManager:
else:
cached = self.sessions[session_id]
# 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)
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the

BIN
cuda-keyring_1.1-1_all.deb Normal file

Binary file not shown.

View file

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

66
docs/improvement-tasks.md Normal file
View 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)

View file

@ -751,7 +751,13 @@ async def build_chat_context(
sess.model = norm
# 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
# placed immediately before the latest user turn, NOT folded into the

View file

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

View file

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

View file

@ -61,7 +61,7 @@ def _sanitize_label(label: str) -> str:
return label
def untrusted_context_message(label: str, content: Any) -> Dict[str, Any]:
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.
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.
The source label and the body content are both placed *inside* the
guarded block where the LLM treats them as untrusted data.
Returns None when content is empty or whitespace-only avoids
injecting blank wrapper blocks that confuse the model into thinking
the user pasted a placeholder template.
"""
text = "" if content is None else str(content).strip()
if not text:
return None
safe_label = _sanitize_label(label)
text = "" if content is None else str(content)
text = _escape_guard_markers(text)
return {
"role": "user",

View file

@ -187,7 +187,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "create_document",
"description": "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": {
"type": "object",
"properties": {
@ -203,7 +203,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"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": {
"type": "object",
"properties": {
@ -752,15 +752,16 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"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": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "delete", "tidy"]},
"document_id": {"type": "string", "description": "Document ID (for delete)"},
"action": {"type": "string", "enum": ["list", "read", "view", "open", "get", "delete", "tidy"]},
"document_id": {"type": "string", "description": "Document ID (for read, delete)"},
"search": {"type": "string", "description": "Search query (for list)"},
"language": {"type": "string", "description": "Filter by language (for list)"},
"limit": {"type": "integer", "description": "Max results (for list, default 50)"}
"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"]
}

View file

@ -1909,19 +1909,20 @@ function initializeEventListeners() {
// genuinely taller than the room above the button.
function positionMenu() {
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.bottom = 'auto';
menu.style.maxHeight = ''; // reset so we can measure the natural height
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 h = Math.min(natural, avail);
if (natural > avail) { // only cap + scroll when it doesn't fit
menu.style.maxHeight = avail + 'px';
menu.style.overflowY = 'auto';
}
menu.style.top = (r.top - 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
// mobile keyboard collapses. preventDefault on pointerdown keeps the

View file

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

View file

@ -293,10 +293,11 @@ function commitCurrent() {
function position(p, anchor) {
const rect = anchor.getBoundingClientRect();
const pRect = p.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + 6;
const zc = parseFloat(getComputedStyle(document.documentElement).zoom) || 1;
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 (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 (top < 8) top = 8;
p.style.left = left + 'px';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -177,11 +177,43 @@ html {
(a catch-22 you can't reach the control to turn the size back down). Divide
each max-height by the same factor to keep the original on-screen footprint.
Desktop only the mobile `!important` full-sheet rules win on small screens
and stay top-anchored, so their headers are already visible. */
:root.ui-scale-125 .modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 .cal-modal-content { max-height: calc(88dvh / 1.25); }
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
and stay top-anchored, so their headers are already visible.
Comprehensive coverage: every modal-content variant, standalone panes,
and user-resized windows that carry inline height styles. Users who
changed zoom/font settings saw headers and close buttons pushed
off-screen because only a subset of windows had the divisor. */
:root.ui-scale-125 .modal-content,
:root.ui-scale-125 .cal-modal-content,
:root.ui-scale-125 .settings-modal-content,
:root.ui-scale-125 .memory-modal-content,
:root.ui-scale-125 .tasks-modal-content,
:root.ui-scale-125 .preset-modal-content,
:root.ui-scale-125 .doclib-modal-content,
:root.ui-scale-125 .gallery-modal-content,
:root.ui-scale-125 .notes-pane,
:root.ui-scale-125 #research-overlay .modal-content,
:root.ui-scale-125 #research-overlay,
:root.ui-scale-125 #compare-model-overlay .modal-content,
:root.ui-scale-125 #compare-model-overlay,
:root.ui-scale-125 #email-lib-modal .modal-content,
:root.ui-scale-125 #doclib-modal .modal-content {
max-height: calc(85dvh / 1.25);
}
/* Any modal-content carrying an inline height style (user-resized) gets
viewport-clamped this catches windows whose saved localStorage size
was set before a zoom change and would otherwise render off-screen. */
:root.ui-scale-125 .modal-content[style*="height"],
:root.ui-scale-125 .cal-modal-content[style*="height"],
:root.ui-scale-125 .settings-modal-content[style*="height"],
:root.ui-scale-125 .memory-modal-content[style*="height"],
:root.ui-scale-125 .tasks-modal-content[style*="height"],
:root.ui-scale-125 .preset-modal-content[style*="height"],
:root.ui-scale-125 .doclib-modal-content[style*="height"],
:root.ui-scale-125 .gallery-modal-content[style*="height"],
:root.ui-scale-125 .notes-pane[style*="height"] {
max-height: calc(100dvh / 1.25 - 16px) !important;
}
/* Cookbook is the one modal that set its height inline (94vh), which beat the
.modal-content compensation above and overflowed the viewport at 1.25x
(header + close button pushed off-screen). Own its height here so the same
@ -194,6 +226,8 @@ html {
1.25x compensation applies. */
.pdf-export-overlay .modal-content { max-height: 86vh; }
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
/* Theme popup: cap at a reasonable fixed height regardless of zoom */
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
/* ── Background Patterns ── */
@ -3853,6 +3887,7 @@ body.bg-pattern-sparkles {
min-height:80px;
}
.edit-textarea:focus { border-color:var(--red); }
.msg-user.editing { width: 85% !important; }
.edit-save-btn, .edit-cancel-btn {
background:var(--bg); color:var(--fg);
border:1px solid var(--border); border-radius:6px;