Compare commits

..
246 changed files with 1977 additions and 15437 deletions
-7
View File
@@ -256,10 +256,3 @@ jobs:
env:
PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
- name: Playwright mobile layout checks (shard 1/4 only)
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
run: |
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \
--reporter=line
-2
View File
@@ -53,8 +53,6 @@ services/data/
# Test capture artifacts (browser session dumps may contain personal content)
.playwright-mcp/
test-results/
playwright-report/
# OS
.DS_Store
-12
View File
@@ -35,15 +35,3 @@ regexTarget = "secret"
regexes = [
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
]
[[allowlists]]
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
targetRules = ["generic-api-key"]
condition = "AND"
paths = [
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
]
regexTarget = "secret"
regexes = [
'''^sk-123456789$''',
]
+3 -18
View File
@@ -1,22 +1,9 @@
# Both stages need the fixed native decoder, including curl in the builder and
# distro Node in the runtime. A cached Python base may predate the Debian fix.
FROM python:3.14.9-slim AS brotli-base
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
# Check inherited trust before the first HTTPS package request, retaining Debian's
# signed archive verification. Refresh only the explicit native dependency.
RUN python /usr/local/bin/check-ca-trust.py --system \
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
&& python /usr/local/bin/check-brotli.py \
&& rm -rf /var/lib/apt/lists/*
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale.
FROM brotli-base AS realesrgan-wheels
FROM python:3.14.9-slim AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
@@ -27,7 +14,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM brotli-base
FROM python:3.14.9-slim
# System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes,
@@ -65,7 +52,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0t64 \
libxcb1 \
libmagic1 \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
&& rm -rf /var/lib/apt/lists/*
# Refresh both Python trust bundles before installing application dependencies.
@@ -146,8 +132,7 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
# Check gRPC's effective defaults after all Python dependencies are installed.
# The application minimum fixes its bundled roots; the checked override also
# keeps the effective Docker selection consistent with system trust.
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
# Copy app code
COPY . .
+4 -11
View File
@@ -119,26 +119,19 @@ Enabled automatically when the editor opens (`window.__geEditorBridge`).
- The page runs the op via the control API and replies:
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
With `wait:true` and one step the call blocks and returns the editor's result.
Otherwise use `POST /api/editor/bridge/await {id, timeout}` for each queued id.
Timeouts are seconds, greater than zero and at most 900. Pending results expire
after the command timeout; an unknown or another owner's id returns 404.
With `wait:true` the call blocks and returns the editor's result.
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
```bash
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8}'
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
```
Session scoping uses the authenticated owner and session as separate values. A
bound editor-tool turn uses its server-owned session, not a model-selected one.
The bridge is a **browser-only** flow: it requires an editor page to be open.
For `/api/editor/agent`, clients must send `incognito: true` for private image
results; omitted or false requests use normal gallery persistence. These
owner-scoped routes do not accept delegated API tokens without a scope contract.
Session scoping is `owner:session`, so an agent can only drive editors owned by the
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
---
+2 -2
View File
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
"prompt": {"type": "string", "description": "Image description prompt"},
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
},
"required": ["prompt"],
},
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if not model_spec:
model_spec = _settings.get("image_model", "")
if "quality" not in arguments and _settings.get("image_quality"):
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
# Auto-detect best available image model
+1 -5
View File
@@ -9,11 +9,7 @@
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:install": "playwright install chromium firefox webkit",
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:install": "playwright install chromium webkit"
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.7.0",
-4
View File
@@ -887,8 +887,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Create a new integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = add_integration(body)
return {"ok": True, "integration": mask_integration_secret(item)}
@@ -897,8 +895,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Update an existing integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = update_integration(integration_id, body)
if not item:
raise HTTPException(404, "Integration not found")
+16 -16
View File
@@ -80,18 +80,6 @@ def _require_user(request: Request) -> str:
return FALLBACK_OWNER
async def _json_object_body(request: Request) -> dict:
"""Parse a JSON object body. An unreadable body counts as empty; a body
that is valid JSON but not an object (a list, string or number) is a 400."""
try:
body = await request.json()
except Exception:
return {}
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
return body
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
if not cal:
@@ -1006,7 +994,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request)
body = await _json_object_body(request)
try:
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip():
_save_caldav_accounts(owner, [])
@@ -1061,7 +1052,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
"""Add a new CalDAV account."""
import uuid as _uuid
owner = _require_user(request)
body = await _json_object_body(request)
try:
body = await request.json()
except Exception:
body = {}
from src.caldav_sync import validate_caldav_url
try:
url = validate_caldav_url(body.get("url", ""))
@@ -1086,7 +1080,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id."""
owner = _require_user(request)
body = await _json_object_body(request)
try:
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is None:
@@ -1127,7 +1124,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
test an already-saved account. Falls back to the first saved account
when nothing is provided."""
owner = _require_user(request)
body = await _json_object_body(request)
try:
body = await request.json()
except Exception:
body = {}
url = (body.get("url") or "").strip()
user = (body.get("username") or "").strip()
pw = body.get("password") or ""
+21 -89
View File
@@ -569,16 +569,6 @@ async def auto_name_session(session_manager, sess):
if not first_msg:
return
from src.owner_identity import is_request_sentinel_owner
owner = getattr(sess, "owner", None)
invalid_owner = (
not isinstance(owner, str) or not owner.strip()
or owner != owner.strip() or is_request_sentinel_owner(owner)
)
if invalid_owner:
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
return
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
model_name = str(getattr(sess, "model", "") or "")
@@ -593,25 +583,15 @@ async def auto_name_session(session_manager, sess):
except Exception:
_is_spec_model = False
if _is_spec_model and endpoint_url:
try:
from src.database import SessionLocal
from src.endpoint_resolver import (build_chat_url, build_headers,
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
from src.owner_identity import auth_disabled
owner = getattr(sess, "owner", None)
if not owner and not auth_disabled():
raise ValueError("Session has no owner")
db = SessionLocal()
_headers = getattr(sess, "headers", None)
if isinstance(_headers, str):
try:
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
title_url = build_chat_url(base)
title_headers = build_headers(key, base)
finally:
db.close()
_headers = json.loads(_headers)
except Exception:
_headers = None
try:
title = await llm_call_async(
title_url,
endpoint_url,
model_name,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
@@ -619,7 +599,7 @@ async def auto_name_session(session_manager, sess):
],
temperature=0.3,
max_tokens=32,
headers=title_headers,
headers=_headers or None,
timeout=20,
thinking_mode="off",
)
@@ -645,14 +625,6 @@ async def auto_name_session(session_manager, sess):
return
owner = getattr(sess, "owner", None)
from src.owner_identity import auth_disabled
if not owner and not auth_disabled():
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
return
from src.task_endpoint import resolve_task_endpoint
t_url, t_model, t_headers = resolve_task_endpoint(
sess.endpoint_url, sess.model, sess.headers, owner=owner
)
@@ -660,64 +632,24 @@ async def auto_name_session(session_manager, sess):
logger.debug("[auto-name] No model provided, skipping")
return
sess_url = getattr(sess, "endpoint_url", None)
if t_url and sess_url and t_url == sess_url:
from src.database import SessionLocal
from src.endpoint_resolver import (
build_chat_url,
build_headers,
resolve_endpoint,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
db = SessionLocal()
try:
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
t_url = build_chat_url(base)
t_headers = build_headers(key, base)
except Exception as e:
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
t_url, t_model, t_headers = None, None, None
for prefix in ("utility", "default"):
try:
u, m, h = resolve_endpoint(prefix, owner=owner)
if u and m:
t_url, t_model, t_headers = u, m, h
break
except Exception:
pass
finally:
db.close()
if not t_url or not t_model:
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
return
# max_tokens big enough that reasoning models (Minimax M2,
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
# plus the actual title — 200 used to clip them mid-reasoning
# so strip_think left an empty string and no rename happened.
# Timeout matches: 60s gives slow local reasoners room to finish.
try:
title = await llm_call_async(
t_url,
t_model,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
)
except Exception as e:
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
title = ""
title = await llm_call_async(
t_url,
t_model,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
)
title = title.strip().strip('"\'').strip()
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
+2 -7
View File
@@ -331,7 +331,6 @@ def _is_external_discovery_request(text: str) -> bool:
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
def _mentions_pdf_url(value: str) -> bool:
@@ -2694,15 +2693,11 @@ def setup_chat_routes(
# Scoped photo_editor agent context: the Quick Edit popup (and any
# image-edit chat turn) may carry the open image-editor session id and a
# canvas snapshot so the editor tool family can drive it in-process.
# The id is quoted into a system message, so accept only an
# identifier; any other value is treated as no open editor.
editor_session = str(
editor_session = (
form_data.get("editor_session")
or form_data.get("editor_session_id")
or ""
).strip()
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
editor_session = ""
photo_editor_requested = (
str(form_data.get("photo_editor") or "").strip().lower()
in {"1", "true", "yes", "on"}
@@ -3526,7 +3521,7 @@ def setup_chat_routes(
if not _privs.get("can_use_documents", True):
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
if not _privs.get("can_generate_images", True):
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
disabled_tools.update({"generate_image", "edit_image"})
if not _privs.get("can_manage_memory", True):
disabled_tools.update({"manage_memory", "manage_skills"})
if not _privs.get("can_use_research", True):
+1 -165
View File
@@ -14,7 +14,6 @@ from fastapi import HTTPException
from pydantic import BaseModel
from routes._validators import validate_remote_host, validate_ssh_port
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
from core.platform_compat import _ssh_exec_argv
logger = logging.getLogger(__name__)
@@ -379,7 +378,6 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
"""
lines = [
"import json, os, re, shutil, subprocess, urllib.request",
*HF_PARTIAL_BLOB_HELPER.splitlines(),
"models = []",
"seen = set()",
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
@@ -438,10 +436,8 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" sz, nf, ic = 0, 0, False",
" if os.path.isdir(blobs):",
" for f in os.scandir(blobs):",
" if f.name.endswith('.incomplete'):",
" if not is_partial_blob(f.path): continue",
" ic = True",
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" snap = os.path.join(cache, d, 'snapshots')",
" def snapshot_size():",
" total, count, incomplete = 0, 0, False",
@@ -1034,166 +1030,6 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_resolve_llama_server() {')
runner_lines.append(' _odysseus_llama_bin=""')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' local _saved_ifs="$IFS"')
runner_lines.append(" IFS=':'")
runner_lines.append(' local -a _parts')
runner_lines.append(' read -r -a _parts <<< "$PATH"')
runner_lines.append(' IFS="$_saved_ifs"')
runner_lines.append(' local _dir _cand')
runner_lines.append(' for _dir in "${_parts[@]}"; do')
runner_lines.append(' [ -n "$_dir" ] || continue')
runner_lines.append(' _cand="$_dir/llama-server"')
runner_lines.append(' [ -x "$_cand" ] || continue')
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
runner_lines.append(' continue')
runner_lines.append(' fi')
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
runner_lines.append(' _odysseus_llama_bin="$_cand"')
runner_lines.append(' break')
runner_lines.append(' fi')
runner_lines.append(' done')
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' local _cand_dir')
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
runner_lines.append(' fi')
runner_lines.append(' hash -r')
runner_lines.append(' return 0')
runner_lines.append(' fi')
runner_lines.append(' return 1')
runner_lines.append('}')
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that generates the Odysseus compatibility shim."""
_append_llama_server_resolution_definition_lines(runner_lines)
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_create_llama_shim() {')
runner_lines.append(' local _target="$HOME/bin/llama-server"')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
runner_lines.append(' : # existing Odysseus shim — safe to replace')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
runner_lines.append(' local _tmp')
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
runner_lines.append(' return 1')
runner_lines.append(' }')
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' then')
runner_lines.append(' rm -f -- "$_tmp"')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' hash -r')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append('}')
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
Invariants:
- Never touch an arbitrary user-owned executable or symlink.
- If ~/bin/llama-server is an Odysseus generated shim:
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
so the native binary is used.
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
and hash -r so the fallback/build path is triggered.
- If no healthy native server exists and llama_cpp.server is working, preserve it.
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
ensure the launched executable is the validated candidate.
"""
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('_odysseus_resolve_llama_server || true')
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
command is unresolvable, the compatibility launcher is created. If no usable
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
"""
err_msg = (
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
if platform_hint
else "ERROR: llama.cpp serving is not available after install/build attempts."
)
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_create_llama_shim')
runner_lines.append(' _odysseus_resolve_llama_server || true')
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(f' echo "{err_msg}"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
"""Shell command that clears the Cookbook-managed llama.cpp build.
+4 -18
View File
@@ -8,20 +8,6 @@ import re
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# Shared source embedded in local/remote inventory and status probes. A blank
# retry marker beside its completed, nonempty content-addressed blob is stale;
# a blank marker alone may be a download that has not received its first byte.
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
if not path.endswith('.incomplete'):
return False
try:
completed = path[:-len('.incomplete')]
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
or os.path.getsize(completed) == 0)
except OSError:
return True
"""
# Probe scripts for the dead-session download check, run as
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
# cache_root is the task's custom download dir, '' for the default HF cache.
@@ -29,7 +15,7 @@ HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
# the probe process's own environment knows nothing about it.
HF_CACHE_COMPLETE_PROBE = (
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"import os,sys;"
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
@@ -38,18 +24,18 @@ HF_CACHE_COMPLETE_PROBE = (
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)"
)
HF_CACHE_INCOMPLETE_PROBE = (
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"import os,sys;"
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if inc else 1)"
)
+57 -17
View File
@@ -57,11 +57,9 @@ from routes.cookbook_helpers import (
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_diagnose_serve_output, run_ssh_command_async,
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
_normalize_llama_cpp_python_cache_types,
_append_llama_server_shim_definition_lines,
_append_llama_server_shim_reconciliation_lines,
_append_llama_server_finalization_lines,
ModelDownloadRequest, ServeRequest,
)
@@ -2163,7 +2161,7 @@ def setup_cookbook_routes() -> APIRouter:
ps_lines.append('}')
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
ps_lines.append('# Auto-install llama-cpp-python if missing')
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
ps_lines.append(' python -m pip install llama-cpp-python[server]')
@@ -2249,17 +2247,14 @@ def setup_cookbook_routes() -> APIRouter:
# ollama is found (otherwise macOS falls back to a slow source build).
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
_append_llama_server_shim_definition_lines(runner_lines)
_append_llama_server_shim_reconciliation_lines(runner_lines)
runner_lines.append('if [ -d /data/data/com.termux ]; then')
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' pkg install -y cmake 2>/dev/null')
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
runner_lines.append(' fi')
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
@@ -2276,7 +2271,8 @@ def setup_cookbook_routes() -> APIRouter:
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
# explicit so the binary is optimized (Metal auto-enables on macOS).
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
runner_lines.append(' else')
_append_llama_cpp_linux_accel_build_lines(runner_lines)
runner_lines.append(' fi')
@@ -2300,13 +2296,58 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' _odysseus_resolve_llama_server || true')
# If the native build failed, fall back to the Python bindings.
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
# SHORT-CIRCUIT before the build/pip fallback: if the
# native binary is missing but llama_cpp Python is already
# installed, drop a wrapper at ~/bin/llama-server that
# translates llama-server CLI args to llama_cpp.server's
# underscore-style flags. The user's serve command stays
# `llama-server ...` and "just works" — no build, no cmake,
# no second install. This is the path that unblocks every
# remote where pip-installed llama-cpp-python is already
# working but Cookbook used to insist on a native binary.
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' chmod +x ~/bin/llama-server')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append(' fi')
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
runner_lines.append(' fi')
_append_llama_server_finalization_lines(runner_lines)
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
runner_lines.append('fi')
elif re.search(r"\bollama\s+serve\b", req.cmd):
handled_ollama_serve = True
@@ -4302,7 +4343,7 @@ def setup_cookbook_routes() -> APIRouter:
tmux output can stop at a stale progress line if the pane/session
disappears before Cookbook captures the final DOWNLOAD_OK marker.
In that case, trust the cache shape: a snapshot directory with files
and no unfinished blobs means HuggingFace finished materializing the
and no *.incomplete blobs means HuggingFace finished materializing the
model. cache_root is the task's custom download dir — the runner
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
not wherever this probe's environment says.
@@ -4327,8 +4368,7 @@ def setup_cookbook_routes() -> APIRouter:
"""Best-effort check for resumable HF partial blobs.
A lost SSH/tmux session can leave a real download still incomplete.
Treat unfinished blobs (excluding empty markers paired with a
completed blob) as stronger evidence than stale
Treat any *.incomplete blob as stronger evidence than stale
"100%" lines in the captured pane output.
"""
if not repo_id or "/" not in repo_id:
+1 -3
View File
@@ -781,11 +781,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception as e:
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
data = {}
ids = data.get("ids") if isinstance(data, dict) else None
ids = data.get("ids") or []
if not ids:
raise HTTPException(400, "No documents specified")
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "ids must be a list of document ids")
_ext = {
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
+94 -294
View File
@@ -21,12 +21,11 @@ import asyncio
import inspect
import json
import logging
import math
import re
import uuid
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from src import editor_style_guide as style_guide
@@ -1170,111 +1169,16 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
op = str(step.get("op") or "")
if op not in _OP_SET:
continue
params = step.get("params")
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
out.append({"op": op, "params": step.get("params") or {}})
return out
async def _json_object(request: Request) -> Dict[str, Any]:
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
raise HTTPException(400, "Expected a JSON object")
if not isinstance(body, dict):
raise HTTPException(400, "Expected a JSON object")
return body
def _prompt(value: Any) -> str:
text = str(value or "").strip()
if len(text) > 4096:
raise HTTPException(413, "Editor prompt is too long")
return text
def _bridge_timeout(value: Any) -> float:
try:
timeout = float(value or _BRIDGE_DEFAULT_TIMEOUT)
except (TypeError, ValueError, OverflowError):
raise HTTPException(400, "Invalid bridge timeout")
if not math.isfinite(timeout) or timeout <= 0 or timeout > 900:
raise HTTPException(400, "Invalid bridge timeout")
return timeout
def _is_admin(request: Request) -> bool:
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
Delegated credentials (bearer API tokens) are explicitly rejected:
per src/tool_security.py, bearer tokens are long-lived credentials handed to
third parties/scripts and must remain capped at non-admin policies even
when minted by an administrator.
"""
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
if getattr(request.state, "api_token", False):
return False
# 2. In-process internal tool bypass (loopback agent tools)
try:
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
import secrets
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
return True
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
return True
except Exception:
pass
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
try:
from src.auth_helpers import _auth_disabled
if _auth_disabled():
return True
except Exception:
pass
# 4. Middleware require_admin
try:
from core.middleware import require_admin
require_admin(request)
return True
except Exception:
pass
# 5. Direct session/user check when request.state.current_user is set
try:
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr and getattr(auth_mgr, "is_configured", False):
from src.auth_helpers import get_current_user
user = get_current_user(request) or getattr(request.state, "current_user", None)
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
return True
except Exception:
pass
return False
def _user(request: Request) -> Optional[str]:
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
if getattr(request.state, "api_token", False):
from src.auth_helpers import require_api_token_scope
return require_api_token_scope(request, "chat")
from src.auth_helpers import get_current_user, _auth_disabled
u = get_current_user(request)
if u:
return u
if _auth_disabled():
try:
from src.auth_helpers import get_current_user
return get_current_user(request)
except Exception:
return None
auth_mgr = getattr(request.app.state, "auth_manager", None)
if auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
raise HTTPException(401, "Not authenticated")
client = getattr(request, "client", None)
host = (client.host if client else "") or ""
if host in ("127.0.0.1", "::1", "localhost", "testclient") or not host:
return None
raise HTTPException(401, "Not authenticated")
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
@@ -1302,14 +1206,17 @@ def setup_editor_agent_routes() -> APIRouter:
if _EDITOR_PLANNER is None:
set_editor_planner(_default_editor_planner)
# Probe the local editor-agent model server (non-blocking) so the planner
# can use it for vision planning when reachable.
editor_agent_model.startup_probe()
# scoped session -> list of that session's live subscriber queues; id -> result future.
# A command is BROADCAST to every live subscriber (per-connection queue), never to a
# single shared queue: EventSource reconnects overlap, and with one shared queue a
# command could be consumed by a stale subscriber while the active page got nothing.
subscribers: Dict[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
max_pending = 1024
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
backlog: Dict[str, List[dict]] = {}
pending: Dict[str, asyncio.Future] = {}
def _subscribe(session: str) -> "asyncio.Queue[dict]":
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
@@ -1346,30 +1253,17 @@ def setup_editor_agent_routes() -> APIRouter:
del bl[0: len(bl) - _QUEUE_MAX]
return 0
def _expire_command(session, cid, fut):
entry = pending.get(cid)
if entry is not None and entry[1] is fut:
pending.pop(cid, None)
if not fut.done():
fut.cancel()
queued = backlog.get(session)
if queued:
backlog[session] = [cmd for cmd in queued if cmd.get("id") != cid]
if not backlog[session]:
backlog.pop(session, None)
def _scoped(session: Optional[str], owner: Optional[str]) -> Tuple[Optional[str], str]:
# Usernames and session ids may both contain colons.
return (owner, str(session or 'default'))
def _scope_label(scope: Tuple[Optional[str], str]) -> str:
return f"{scope[0] or 'anon'}:{scope[1]}"
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
return f"{owner or 'anon'}:{session or 'default'}"
@router.post("/api/editor/plan")
async def plan(request: Request) -> JSONResponse:
owner = _user(request)
body = await _json_object(request)
prompt = _prompt(body.get("prompt"))
try:
body = await request.json()
except Exception:
body = {}
prompt = str(body.get("prompt") or "").strip()
state = body.get("state") or {}
mode = str(body.get("mode") or "plan").strip().lower()
screenshot = body.get("screenshot")
@@ -1403,13 +1297,9 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception:
raw = None
_used = editor_agent_model.get_last_model()
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
if isinstance(parsed, dict):
parsed["agent_model"] = model_used
if mode != "check":
@@ -1473,49 +1363,31 @@ def setup_editor_agent_routes() -> APIRouter:
@router.post("/api/editor/bridge/command")
async def bridge_command(request: Request) -> JSONResponse:
owner = _user(request)
body = await _json_object(request)
try:
body = await request.json()
except Exception:
body = {}
session = _scoped(body.get("session"), owner)
steps = _normalize_steps(body)
wait = bool(body.get("wait"))
timeout = _bridge_timeout(body.get("timeout"))
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
ids: List[str] = []
if len(pending) + len(steps) > max_pending:
raise HTTPException(429, "Too many pending editor commands")
wait_ids: List[str] = []
for step in steps:
cid = uuid.uuid4().hex
ids.append(cid)
fut = asyncio.get_event_loop().create_future()
pending[cid] = (owner, fut)
if wait:
pending[cid] = asyncio.get_event_loop().create_future()
wait_ids.append(cid)
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
if wait and len(steps) == 1:
cid = ids[0]
entry = pending.get(cid)
fut = entry[1]
if wait and len(wait_ids) == 1:
try:
res = await asyncio.wait_for(fut, timeout=timeout)
if isinstance(res, dict):
res.setdefault("status", "completed" if res.get("ok") else "failed")
res.setdefault("completed", bool(res.get("ok")))
return JSONResponse(res)
return JSONResponse({"id": cid, "ok": True, "status": "completed", "completed": True, "result": res})
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
return JSONResponse(result)
except asyncio.TimeoutError:
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
finally:
_expire_command(session, cid, fut)
else:
for cid in ids:
entry = pending.get(cid)
if entry is not None:
asyncio.get_running_loop().call_later(timeout, _expire_command, session, cid, entry[1])
return JSONResponse({
"queued": ids,
"session": _scope_label(session),
"status": "queued",
"completed": False,
"ok": False,
})
pending.pop(wait_ids[0], None)
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
return JSONResponse({"queued": ids, "session": session})
@router.get("/api/editor/bridge/events")
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
@@ -1540,50 +1412,38 @@ def setup_editor_agent_routes() -> APIRouter:
@router.post("/api/editor/bridge/result")
async def bridge_result(request: Request) -> JSONResponse:
owner = _user(request)
body = await _json_object(request)
try:
body = await request.json()
except Exception:
body = {}
cid = str(body.get("id") or "")
entry = pending.get(cid)
if entry is None:
return JSONResponse({"ok": True})
if entry[0] != owner:
return JSONResponse({"ok": False, "error": "unknown command"}, status_code=404)
_, fut = entry
fut = pending.pop(cid, None)
if fut is not None and not fut.done():
is_ok = bool(body.get("ok"))
fut.set_result({
"id": cid,
"ok": is_ok,
"status": "completed" if is_ok else "failed",
"completed": is_ok,
"result": body.get("result"),
"error": body.get("error"),
"id": cid, "ok": bool(body.get("ok")),
"result": body.get("result"), "error": body.get("error"),
"screenshot": body.get("screenshot"),
})
return JSONResponse({"ok": True})
@router.post("/api/editor/bridge/await")
async def bridge_await(request: Request) -> JSONResponse:
owner = _user(request)
body = await _json_object(request)
cid = str(body.get("id") or "")
timeout = _bridge_timeout(body.get("timeout"))
entry = pending.get(cid)
if entry is None or entry[0] != owner:
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
fut = entry[1]
try:
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
if pending.get(cid) == (owner, fut):
pending.pop(cid, None)
res = dict(result) if isinstance(result, dict) else {"result": result, "ok": True}
res.setdefault("status", "completed" if res.get("ok") else "failed")
res["completed"] = bool(res.get("ok"))
return JSONResponse(res)
body = await request.json()
except Exception:
body = {}
cid = str(body.get("id") or "")
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
fut = pending.get(cid)
if fut is None:
fut = asyncio.get_event_loop().create_future()
pending[cid] = fut
try:
result = await asyncio.wait_for(fut, timeout=timeout)
return JSONResponse(result)
except asyncio.TimeoutError:
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
except asyncio.CancelledError:
return JSONResponse({"ok": False, "error": "cancelled", "status": "cancelled", "completed": False, "id": cid})
pending.pop(cid, None)
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
_AGENT_SYSTEM_PROMPT = (
"You are the photo editor agent. You drive the open image editor and "
@@ -1634,14 +1494,11 @@ def setup_editor_agent_routes() -> APIRouter:
overwritten.
"""
owner = _user(request)
body = await _json_object(request)
from src.settings import get_setting
disabled = get_setting("disabled_tools", []) or []
if not isinstance(disabled, (list, tuple, set)):
disabled = []
if "plan_edit" in disabled:
raise HTTPException(403, "Editor tool is disabled")
prompt = _prompt(body.get("prompt"))
try:
body = await request.json()
except Exception:
body = {}
prompt = str(body.get("prompt") or "").strip()
session = str(body.get("editor_session") or body.get("session") or "default")
state = body.get("state") if isinstance(body.get("state"), dict) else {}
screenshot = body.get("screenshot")
@@ -1649,10 +1506,10 @@ def setup_editor_agent_routes() -> APIRouter:
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
# Optional user-picked PLANNER model (the editor agent/planner model).
agent_model = str(body.get("agent_model") or "").strip()
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
tools_offered = _scoped_editor_tools()
scoped = _scoped(session, owner)
trace: List[Dict[str, Any]] = [
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
{"type": "session", "session": scoped, "tools_offered": tools_offered},
]
if not prompt:
return JSONResponse({
@@ -1683,14 +1540,9 @@ def setup_editor_agent_routes() -> APIRouter:
except Exception:
raw = None
_used = editor_agent_model.get_last_model()
parsed = _parse_plan(raw)
if _used and _used[0]:
model_used = _used[0]
elif parsed:
model_used = agent_model or editor_agent_model.local_model()
else:
model_used = "fallback" if agent_model else editor_agent_model.local_model()
plan = parsed or _fallback_plan(prompt)
model_used = (_used[0] if _used and _used[0]
else (agent_model or editor_agent_model.local_model()))
plan = _parse_plan(raw) or _fallback_plan(prompt)
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
if _strengthened != plan.get("steps"):
plan = {**plan, "steps": _strengthened,
@@ -1749,19 +1601,8 @@ def setup_editor_agent_routes() -> APIRouter:
"results": [],
"agent_model": model_used,
})
# This route dispatches tools directly, outside execute_tool_block.
# Apply the same administrator tool switches before any step runs.
needed = {"plan_edit"}
for step in steps:
op = step.get("op")
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
if needed.intersection(disabled):
raise HTTPException(403, "Editor tool is disabled")
# No image provider: stop before any edit runs and tell the user plainly
# (zero canvas changes; nothing to roll back).
if _plan_uses_ai_content(steps):
from src.auth_helpers import require_privilege
require_privilege(request, "can_generate_images")
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
return JSONResponse({
@@ -1777,12 +1618,6 @@ def setup_editor_agent_routes() -> APIRouter:
"results": [],
"agent_model": model_used,
})
needed = {"plan_edit"}
for step in steps:
op = step.get("op")
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
if needed.intersection(disabled):
raise HTTPException(403, "Editor tool is disabled")
results: List[Dict[str, Any]] = []
for step in steps:
op = step.get("op")
@@ -1794,30 +1629,24 @@ def setup_editor_agent_routes() -> APIRouter:
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
break
tool_payload = dict(params)
# Planner output is untrusted: it cannot redirect this turn to
# another editor session or override an explicit model choice.
tool_payload["session"] = session
tool_payload.setdefault("session", session)
# Route to the user-picked model when one is selected.
if model_choice:
tool_payload["model"] = model_choice
tool_payload.setdefault("model", model_choice)
# Hand the AI step the canvas image the client already captured
# for this request. Without it the tool falls back to a separate
# bridge screenshot, which is not reliably answered right after a
# selection step (the masked path was losing the canvas image).
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
tool_payload["image"] = screenshot
else:
tool_payload.pop("image", None)
tool_payload.setdefault("image", screenshot)
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
try:
_desc, res = await tool_cls().execute(
json.dumps(tool_payload),
{"owner": owner, "session_id": _scope_label(scoped), "editor_session": session,
"incognito": body.get("incognito") is True},
{"owner": owner, "session_id": scoped},
)
except Exception:
logger.exception("Editor image operation failed")
res = {"error": "Editor image operation failed", "exit_code": 1}
except Exception as exc:
res = {"error": str(exc), "exit_code": 1}
res = res if isinstance(res, dict) else {}
ok = res.get("exit_code", 0) == 0
step_result = {
@@ -1835,17 +1664,15 @@ def setup_editor_agent_routes() -> APIRouter:
break
continue
cid = uuid.uuid4().hex
fut = asyncio.get_event_loop().create_future()
pending[cid] = (owner, fut)
pending[cid] = asyncio.get_event_loop().create_future()
_broadcast(scoped, {"id": cid, "op": op, "params": params})
try:
res = await asyncio.wait_for(pending[cid][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT)
except asyncio.TimeoutError:
pending.pop(cid, None)
results.append({"op": op, "ok": False, "error": "timeout"})
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
break
finally:
_expire_command(scoped, cid, fut)
results.append(res)
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
if res.get("ok") is False:
@@ -1856,16 +1683,13 @@ def setup_editor_agent_routes() -> APIRouter:
if steps and not any(r.get("ok") is False for r in results):
try:
_info_id = uuid.uuid4().hex
_info_fut = asyncio.get_event_loop().create_future()
pending[_info_id] = (owner, _info_fut)
pending[_info_id] = asyncio.get_event_loop().create_future()
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
_info = await asyncio.wait_for(pending[_info_id][1], timeout=60)
_info = await asyncio.wait_for(pending[_info_id], timeout=60)
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
final_state = _info["result"]
except Exception:
final_state = {}
finally:
_expire_command(scoped, _info_id, _info_fut)
planned_reply = reply
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
if special_note and special_note.strip() not in reply:
@@ -1902,7 +1726,6 @@ def setup_editor_agent_routes() -> APIRouter:
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
"""
owner = _user(request)
is_admin = _is_admin(request)
models: List[Dict[str, Any]] = []
seen: set = set()
try:
@@ -1925,32 +1748,22 @@ def setup_editor_agent_routes() -> APIRouter:
# recognisably image-capable — the same test the request
# router uses.
image_endpoint = getattr(ep, "model_type", None) == "image"
endpoint_id = getattr(ep, "id", None)
provider = getattr(ep, "name", None)
if not provider:
provider = (
getattr(ep, "base_url", "")
if is_admin
else (f"Endpoint {endpoint_id}" if endpoint_id else "image")
)
provider = provider or "image"
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image"
for name in _endpoint_enabled_models(ep):
if not image_endpoint and not looks_like_image_generation_model(name):
continue
key = (str(endpoint_id or provider), name)
key = (provider, name)
if key in seen:
continue
seen.add(key)
entry = {
models.append({
"id": name,
"label": name,
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
"provider": provider,
"endpoint_id": endpoint_id,
}
if is_admin:
entry["base_url"] = getattr(ep, "base_url", "") or ""
models.append(entry)
"endpoint_id": getattr(ep, "id", None),
"base_url": getattr(ep, "base_url", "") or "",
})
finally:
db.close()
except Exception:
@@ -1961,55 +1774,42 @@ def setup_editor_agent_routes() -> APIRouter:
async def editor_agent_models(request: Request) -> JSONResponse:
"""List the models the editor agent/planner can run on.
Each entry is ``{id, label, source, vision}`` where ``id`` is
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
``hosted`` (the configured default/utility chat model that the planner
falls back to).
For non-admin users, private URLs and internal probe status are omitted.
"""
owner = _user(request)
is_admin = _is_admin(request)
try:
models = await editor_agent_model.list_editor_agent_models(owner)
if not is_admin:
sanitized = []
for m in models:
m_copy = dict(m)
m_copy.pop("base_url", None)
sanitized.append(m_copy)
models = sanitized
except Exception:
logger.warning("editor agent-models lookup failed", exc_info=True)
models = []
probe_info = {}
if is_admin:
probe_info = editor_agent_model.probe_status()
if not probe_info:
editor_agent_model.startup_probe()
probe_info = editor_agent_model.probe_status()
return JSONResponse({
"models": models,
"probe": probe_info,
"probe": editor_agent_model.probe_status(),
})
@router.post("/api/editor/self-check")
async def editor_self_check(request: Request) -> JSONResponse:
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
A configured vision server may not provide browser CORS headers. This route
The local VL server (seneca :8097) sends no CORS headers, so the browser
cannot call it directly and the self-check silently "passed". This route
proxies the two images + intent to the agent VLM and returns
``{done, issues, retry_hint, text}`` (``done=True`` with
``reason="unavailable"`` on any failure so the client never loops).
"""
owner = _user(request)
body = await _json_object(request)
try:
body = await request.json()
except Exception:
body = {}
result = await editor_agent_model.self_check(
body.get("before"),
body.get("after"),
_prompt(body.get("prompt") or body.get("intent")),
str(body.get("prompt") or body.get("intent") or ""),
model=str(body.get("model") or "").strip() or None,
owner=owner,
)
+73 -133
View File
@@ -1827,25 +1827,18 @@ def setup_email_routes():
# a cached message body.
return (account_id or "", folder, str(uid), owner)
_LIST_STALE = {}
_LIST_LOCK = _threading.RLock()
_LIST_CACHE_LOCK = _LIST_LOCK
# Invalidation fences reads already in flight, including background refresh.
_LIST_EPOCH = [0]
_LIST_CACHE_GENERATION = _LIST_EPOCH
def _list_cache_get(key):
with _LIST_LOCK:
v = _LIST_CACHE.get(key)
if not v: return None
if v[0] < _time.monotonic():
_LIST_CACHE.pop(key, None)
return None
return v[1]
v = _LIST_CACHE.get(key)
if not v: return None
if v[0] < _time.monotonic():
_LIST_CACHE.pop(key, None)
return None
return v[1]
_LIST_STALE = {}
def _list_stale_get(key):
with _LIST_LOCK:
return _LIST_STALE.get(key)
return _LIST_STALE.get(key)
def _list_stale_put(key, value):
_LIST_STALE[key] = value
@@ -1856,13 +1849,13 @@ def setup_email_routes():
_LIST_REFRESHING = set()
def _list_cache_put(key, value):
with _LIST_LOCK:
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
# Also snapshot the last-good response for stale-while-revalidate.
_list_stale_put(key, value)
if len(_LIST_CACHE) > 64:
for k in list(_LIST_CACHE.keys())[:-32]:
_LIST_CACHE.pop(k, None)
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
# Also snapshot the last-good response for stale-while-revalidate.
_list_stale_put(key, value)
# Cap size
if len(_LIST_CACHE) > 64:
for k in list(_LIST_CACHE.keys())[:-32]:
_LIST_CACHE.pop(k, None)
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
"""Refresh a list snapshot off the event loop, at most once per key.
@@ -1870,20 +1863,15 @@ def setup_email_routes():
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
the TTL cache and the stale snapshot so the next list view is warm.
"""
with _LIST_LOCK:
if cache_key in _LIST_REFRESHING:
return
_LIST_REFRESHING.add(cache_key)
epoch = _LIST_EPOCH[0]
if cache_key in _LIST_REFRESHING:
return
_LIST_REFRESHING.add(cache_key)
def _run():
try:
refreshed = _list_emails_sync(*sync_args)
if refreshed and not refreshed.get("error"):
with _LIST_LOCK:
if epoch != _LIST_EPOCH[0]:
return
_list_cache_put(cache_key, refreshed)
_list_cache_put(cache_key, refreshed)
try:
_schedule_recent_email_warm(
refreshed.get("emails") or [], folder, account_id, owner)
@@ -1892,8 +1880,7 @@ def setup_email_routes():
except Exception as exc: # pragma: no cover - background best effort
logger.debug("email list background refresh failed: %s", exc)
finally:
with _LIST_LOCK:
_LIST_REFRESHING.discard(cache_key)
_LIST_REFRESHING.discard(cache_key)
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
@@ -1930,74 +1917,37 @@ def setup_email_routes():
hook(account_id, folder)
except Exception:
pass
with _LIST_LOCK:
_LIST_EPOCH[0] += 1
if account_id is None and folder is None:
_LIST_CACHE.clear()
_LIST_STALE.clear()
return
for cache in (_LIST_CACHE, _LIST_STALE):
for k in list(cache.keys()):
k_acct = k[0] if len(k) > 0 else ""
k_folder = k[1] if len(k) > 1 else ""
if (account_id is None or k_acct == (account_id or "")) and \
(folder is None or k_folder == folder):
cache.pop(k, None)
def _index_update_flags(owner, account_id, folder, uid, flag, add):
with _LIST_LOCK:
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
_invalidate_list_cache(account_id, folder)
def _index_delete(owner, account_id, folder, uid):
with _LIST_LOCK:
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
if account_id is None and folder is None:
_LIST_CACHE.clear()
return
for k in list(_LIST_CACHE.keys()):
k_acct = k[0] if len(k) > 0 else ""
k_folder = k[1] if len(k) > 1 else ""
if (account_id is None or k_acct == (account_id or "")) and \
(folder is None or k_folder == folder):
_LIST_CACHE.pop(k, None)
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
uid_s = str(uid)
with _LIST_LOCK:
for key, (expires_at, value) in list(_LIST_CACHE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
emails = list((value or {}).get("emails") or [])
changed = False
if seen and key[2] == "unread":
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
if len(kept) != len(emails):
value = dict(value)
value["emails"] = kept
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
for key, (expires_at, value) in list(_LIST_CACHE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
emails = list((value or {}).get("emails") or [])
changed = False
if seen and key[2] == "unread":
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
if len(kept) != len(emails):
value = dict(value)
value["emails"] = kept
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
changed = True
else:
for e in emails:
if str((e or {}).get("uid") or "") == uid_s:
e["is_read"] = bool(seen)
changed = True
else:
for e in emails:
if str((e or {}).get("uid") or "") == uid_s:
e["is_read"] = bool(seen)
changed = True
if changed:
_LIST_CACHE[key] = (expires_at, value)
if key in _LIST_STALE:
_LIST_STALE[key] = value
for key, value in list(_LIST_STALE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
emails = list((value or {}).get("emails") or [])
changed = False
if seen and key[2] == "unread":
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
if len(kept) != len(emails):
value = dict(value)
value["emails"] = kept
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
changed = True
else:
for e in emails:
if str((e or {}).get("uid") or "") == uid_s:
e["is_read"] = bool(seen)
changed = True
if changed:
_LIST_STALE[key] = value
if changed:
_LIST_CACHE[key] = (expires_at, value)
def _read_cache_get(key):
v = _READ_CACHE.get(key)
@@ -2377,8 +2327,6 @@ def setup_email_routes():
SECURITY: `owner` is propagated so when `account_id` is missing,
the fallback config lookup is scoped to this user's accounts only.
"""
with _LIST_CACHE_LOCK:
index_generation = _LIST_CACHE_GENERATION[0]
conn = None
conn_ok = False
try:
@@ -2673,9 +2621,7 @@ def setup_email_routes():
if parsed:
fetched_emails.append(parsed)
cached_rows[parsed["uid"]] = parsed
with _LIST_CACHE_LOCK:
if index_generation == _LIST_CACHE_GENERATION[0]:
_email_index_upsert(owner, account_id, folder, fetched_emails)
_email_index_upsert(owner, account_id, folder, fetched_emails)
for uid_num in uid_order:
row = cached_rows.get(uid_num)
if not row:
@@ -2969,7 +2915,7 @@ def setup_email_routes():
# with a `stale` marker and refresh in the background so the next view
# is warm. Blocking IMAP only remains for a genuine cold start with no
# snapshot and no indexed rows (e.g. a brand-new account/folder).
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
if not from_addr and not has_attachments and not (date_from or date_to):
snapshot = _list_stale_get(ck)
if snapshot is None:
try:
@@ -3004,8 +2950,6 @@ def setup_email_routes():
ck, folder, account_id, owner,
)
return payload
with _LIST_CACHE_LOCK:
cache_generation = _LIST_CACHE_GENERATION[0]
result = await _asyncio.to_thread(
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
@@ -3014,9 +2958,7 @@ def setup_email_routes():
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
with _LIST_CACHE_LOCK:
if cache_generation == _LIST_CACHE_GENERATION[0]:
_list_cache_put(ck, result)
_list_cache_put(ck, result)
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
if elapsed_ms > 1500:
logger.warning(
@@ -3364,7 +3306,7 @@ def setup_email_routes():
conn.select(_q(folder))
moved = _move_email_message(conn, uid, "Junk", role="junk")
if moved:
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception:
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
@@ -3381,7 +3323,7 @@ def setup_email_routes():
if deleted:
conn.expunge()
if deleted:
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception as exc:
delete_error = "Email was unsubscribed but could not be moved to Trash"
@@ -3463,7 +3405,7 @@ def setup_email_routes():
if moved:
changed += 1
cleaned_uids.append(uid)
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
else:
failed += 1
except Exception:
@@ -3552,8 +3494,6 @@ def setup_email_routes():
folder (Gmail does), we transparently swap to All Mail so the
search surfaces archived / labelled emails too. Plain IMAP
accounts fall back to whatever folder the caller specified."""
with _LIST_CACHE_LOCK:
index_generation = _LIST_CACHE_GENERATION[0]
if not q or len(q) < 2:
return {"emails": [], "total": 0, "query": q}
# CRLF in q would terminate the IMAP command early — reject defensively.
@@ -3697,9 +3637,7 @@ def setup_email_routes():
except Exception as e:
logger.warning(f"Error parsing search result {uid}: {e}")
continue
with _LIST_CACHE_LOCK:
if index_generation == _LIST_CACHE_GENERATION[0]:
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
return {
"emails": emails,
@@ -3836,7 +3774,8 @@ def setup_email_routes():
# accepted it, so the index and list cache cannot drift ahead of
# the mailbox.
if mark_seen and not mark_seen_failed:
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
related_attachments = []
if full and not _has_visible_attachments(msg):
@@ -3975,7 +3914,8 @@ def setup_email_routes():
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
if status != "OK":
return False
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
return True
except Exception as e:
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
@@ -5207,7 +5147,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Seen", add=False):
return {"success": False, "error": "Email not found"}
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5227,7 +5167,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
return {"success": False, "error": "Email not found"}
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
_invalidate_list_cache(account_id, folder)
return {"success": True, "flagged": bool(on)}
except Exception as e:
@@ -5245,7 +5185,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Seen", add=True):
return {"success": False, "error": "Email not found"}
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5265,7 +5205,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _move_email_message(conn, uid, "Archive", role="archive"):
return {"success": False, "error": "Email not found"}
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5296,7 +5236,7 @@ def setup_email_routes():
# click or by another mailbox client; it is already gone
# from the requested folder, so do not trap the UI on a
# permanent “Email not found” error.
_index_delete(owner, account_id, folder, str(uid))
_email_index_delete(owner, account_id, folder, str(uid))
_invalidate_list_cache(account_id, folder)
return {"success": True, "already_deleted": True}
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
@@ -5314,9 +5254,9 @@ def setup_email_routes():
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
return {"success": False, "error": "Could not move email to Trash"}
_index_delete(owner, account_id, folder, resolved_uid)
_email_index_delete(owner, account_id, folder, resolved_uid)
if resolved_uid != str(uid):
_index_delete(owner, account_id, folder, str(uid))
_email_index_delete(owner, account_id, folder, str(uid))
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5332,7 +5272,7 @@ def setup_email_routes():
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
return {"success": False, "error": "Email not found"}
conn.expunge()
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5400,7 +5340,7 @@ def setup_email_routes():
pending_expunge.append(uid)
continue
deleted_uids.append(uid)
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
except Exception:
failed_uids.append(uid)
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
@@ -5422,7 +5362,7 @@ def setup_email_routes():
if expunge_ok:
for uid in pending_expunge:
deleted_uids.append(uid)
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
else:
# Prioritize preservation over cleanup: unflag \Deleted from our
# copied messages so future EXPUNGE operations do not destroy them.
@@ -5541,7 +5481,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _move_email_message(conn, uid, dest):
return {"success": False, "error": f"Failed to move to {dest}"}
_index_delete(owner, account_id, folder, uid)
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5627,7 +5567,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Answered", add=True):
return {"success": False, "error": "Email not found"}
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
_clear_done_response_tags(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
return {"success": True}
@@ -5643,7 +5583,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Answered", add=False):
return {"success": False, "error": "Email not found"}
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -6310,7 +6250,7 @@ def setup_email_routes():
try:
st, _sel = imap.select(_q(_source_folder), readonly=False)
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
_invalidate_list_cache(_account_id, _source_folder)
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
+2 -3
View File
@@ -19,10 +19,9 @@ def _split_family_token(token):
def _derive_family(filename):
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
name = os.path.splitext(filename)[0]
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
# Strip common weight/style suffixes
name = re.sub(
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
r'(?:[-_ ]?(?:Italic|Oblique))?$',
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
'', name, flags=re.IGNORECASE
)
# Replace dashes/underscores with spaces
+32 -48
View File
@@ -683,15 +683,9 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
configured.update(v for v in values if isinstance(v, str))
except (TypeError, ValueError):
pass
if model:
if model not in configured or not looks_like_image_generation_model(model):
continue
else:
if not any(looks_like_image_generation_model(v) for v in configured):
continue
ep_id = str(getattr(ep, "id", "") or "")
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
if model not in configured or not looks_like_image_generation_model(model):
continue
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
if owner and getattr(ep, "owner", None) == owner:
return ep
if fallback is None:
@@ -718,7 +712,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
)
if not ok:
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
async with httpx.AsyncClient(timeout=60) as c2:
ir = await c2.get(url)
if ir.status_code == 200:
return base64.b64encode(ir.content).decode()
@@ -785,17 +779,14 @@ async def _forward_seneca_image_op(
except Exception:
pass
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
try:
result = r.json()
except ValueError as exc:
raise HTTPException(502, "Image endpoint returned invalid JSON") from exc
result = r.json()
raw_b64: Optional[str] = None
used_model = ""
if isinstance(result, dict):
used_model = str(result.get("model") or "")
data = result.get("data")
if isinstance(data, list) and data:
item = data[0] if isinstance(data[0], dict) else {}
item = data[0] or {}
raw_b64 = item.get("b64_json") or None
if not raw_b64 and item.get("url"):
raw_b64 = await _fetch_result_image_b64(item["url"])
@@ -1736,7 +1727,7 @@ def setup_gallery_routes() -> APIRouter:
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
# SSRF hardening: validate a client-supplied endpoint before any
# outbound request (mirrors routes/embedding_routes.py).
if requested_base and "://" in requested_base:
if requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -1851,8 +1842,8 @@ def setup_gallery_routes() -> APIRouter:
headers = {"Authorization": f"Bearer {api_key}"}
try:
logger.debug(
"inpaint_proxy provider edit -> model=%r size=%r base=%s",
oa_model, size, base,
"inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s",
body.get("prompt", ""), oa_model, size, base,
)
async with httpx.AsyncClient(timeout=300) as client:
if is_openrouter:
@@ -1987,9 +1978,9 @@ def setup_gallery_routes() -> APIRouter:
finally:
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
logger.debug(
"inpaint_proxy self-hosted /images/edits -> model=%r "
"inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r "
"size=%r steps=%r endpoint=%s",
data.get("model"), data.get("size"),
data.get("prompt"), data.get("model"), data.get("size"),
data.get("steps"), base,
)
_prep_ms = (time.perf_counter() - _t0) * 1000.0
@@ -2082,7 +2073,7 @@ def setup_gallery_routes() -> APIRouter:
runs off the event loop (large images).
"""
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
if requested_base and "://" in requested_base:
if requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2175,38 +2166,26 @@ def setup_gallery_routes() -> APIRouter:
try:
ep = None
if endpoint_id:
ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
elif requested_base:
ep = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id),
ModelEndpoint, user,
).first()
if ep is None and requested_base:
ep = _visible_image_endpoint_for_base(db, requested_base, user)
if ep is None:
return {"available": False, "reason": "endpoint not found or disabled"}
else:
if ep is None:
ep = _first_visible_image_endpoint(db, user)
if ep is None:
return {"available": False, "reason": "no image endpoint configured"}
base = ep.base_url.rstrip("/")
finally:
db.close()
from src.url_safety import check_outbound_url
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
ok, reason = check_outbound_url(base, block_private=block_private)
if not ok:
# CodeQL #1203: Never expose internal addresses, resolver errors or raw
# exception details to clients or logs. Use a stable diagnostic message
# without interpolating untrusted exception content or destination strings.
logger.warning("edit-progress endpoint rejected: outbound URL policy check failed")
return {"available": False, "reason": "endpoint URL rejected"}
if not base.endswith("/v1"):
base += "/v1"
# Dynamic path segment (validated token above), so not routed through
# _join_checked_gallery_endpoint's exact-match whitelist.
url = f"{base}/images/progress/{request_id}"
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(url)
if r.status_code == 200:
data = r.json()
@@ -2244,7 +2223,7 @@ def setup_gallery_routes() -> APIRouter:
# below, so validate it first (mirrors routes/embedding_routes.py).
# Local-first means loopback/LAN is allowed by default; the cloud
# metadata range and non-HTTP(S) schemes are always rejected.
if requested_base and "://" in requested_base:
if requested_base:
from src.url_safety import check_outbound_url
ok, reason = check_outbound_url(
requested_base,
@@ -2271,7 +2250,7 @@ def setup_gallery_routes() -> APIRouter:
# so the outbound URL never depends directly on request-body input.
db = SessionLocal()
try:
ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
ep = _visible_image_endpoint_for_base(db, requested_base, user)
if not ep:
raise HTTPException(403, "Choose a registered image endpoint")
base = ep.base_url.rstrip("/")
@@ -2433,10 +2412,11 @@ def setup_gallery_routes() -> APIRouter:
image_b64 = body.get("image")
amount = body.get("amount", 50) / 100.0
from PIL import ImageFilter
from PIL import Image, ImageFilter
import base64, io
img = _b64_to_pil_image(image_b64, mode="RGB")
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
# Unsharp mask: radius=2, percent=amount*200, threshold=3
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
@@ -2471,7 +2451,8 @@ def setup_gallery_routes() -> APIRouter:
except ImportError:
raise HTTPException(500, "Server missing a required dependency")
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
src = _b64_to_pil_image(image_b64, mode="RGB")
img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
try:
patch_realesrgan_torchvision_compat()
from realesrgan import RealESRGANer
@@ -2523,7 +2504,8 @@ def setup_gallery_routes() -> APIRouter:
import numpy as np
except ImportError:
raise HTTPException(500, "Server missing a required dependency")
src = _b64_to_pil_image(image_b64, mode="RGB")
img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
try:
patch_realesrgan_torchvision_compat()
from basicsr.archs.rrdbnet_arch import RRDBNet
@@ -2775,7 +2757,8 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image
import base64, io
img = _b64_to_pil_image(image_b64)
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
W, H = img.size
hint = None
@@ -2864,7 +2847,8 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image, ImageFilter, ImageEnhance
import numpy as np
img = _b64_to_pil_image(image_b64, mode="RGB")
img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
# Try GFPGAN first (AI face restoration)
try:
+1 -7
View File
@@ -5,7 +5,6 @@ import os
import uuid
import urllib.parse
import html
import secrets
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
@@ -13,7 +12,7 @@ import logging
import httpx
from core.database import McpServer, SessionLocal
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
from core.middleware import require_admin
from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager
@@ -425,11 +424,6 @@ def setup_mcp_routes(mcp_manager: McpManager):
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
"""
require_admin(request)
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Same boundary as POST /api/tools: an agent turn must not be able
# to re-enable MCP tools an admin switched off.
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
-7
View File
@@ -7,7 +7,6 @@ import json
import hashlib
import ipaddress
import socket
import secrets
import time as _time
import logging
import threading
@@ -3092,12 +3091,6 @@ def setup_model_routes(model_discovery):
def update_tools(body: ToolsUpdate, request: Request):
"""Update which tools are disabled."""
require_admin(request)
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Owner impersonation can carry an admin username, so check the
# authenticated transport rather than request.state.current_user.
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
settings = _load_settings()
settings["disabled_tools"] = body.disabled
_save_settings(settings)
+9 -12
View File
@@ -728,35 +728,32 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
)
if body.title is not None:
note.title = body.title
# Omitted fields preserve their values; explicit null clears the
# nullable fields used by the editor's removal controls.
supplied = body.model_fields_set
if "content" in supplied:
if body.content is not None:
note.content = body.content
if "items" in supplied:
note.items = json.dumps(body.items) if body.items is not None else None
if body.items is not None:
note.items = json.dumps(body.items)
flag_modified(note, "items")
if body.note_type is not None:
note.note_type = body.note_type
if "color" in supplied:
if body.color is not None:
note.color = body.color
if "label" in supplied:
if body.label is not None:
note.label = body.label
if body.pinned is not None:
note.pinned = body.pinned
if body.archived is not None:
note.archived = body.archived
if "due_date" in supplied:
if body.due_date is not None:
note.due_date = body.due_date
if "image_url" in supplied:
if body.image_url is not None:
note.image_url = body.image_url
if "gallery_id" in supplied:
if body.gallery_id is not None:
note.gallery_id = body.gallery_id
if body.repeat is not None:
note.repeat = body.repeat
if body.sort_order is not None:
note.sort_order = body.sort_order
if "agent_session_id" in supplied:
if body.agent_session_id is not None:
note.agent_session_id = body.agent_session_id
db.commit()
+7 -2
View File
@@ -290,9 +290,14 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
# (most sessions have none), not an error. Return HTTP 200 with an
# explicit empty status so loading a session does not log a spurious
# 404; every client treats a non-"running" status as idle anyway.
# Research owned by ANOTHER user answers identically, so the response
# does not reveal whether that research exists.
# A session whose research exists but belongs to ANOTHER user stays a
# 404, so we do not leak the existence of someone else's research.
if not _owns_in_memory(session_id, user):
exists = session_id in getattr(research_handler, "_active_tasks", {})
if not exists:
exists = _find_research_path(session_id) is not None
if exists:
raise HTTPException(404, "No research found for this session")
return {"status": "none"}
status = research_handler.get_status(session_id)
if status is None:
+40 -7
View File
@@ -1315,7 +1315,7 @@ def setup_session_routes(
def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders.
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
to assign folders. When `skip_llm=true` the endpoint returns
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens.
@@ -1326,11 +1326,21 @@ def setup_session_routes(
single_user_mode = not user and _auth_disabled()
user_sessions = session_manager.get_sessions_for_user(user)
# Saved conversations are never disposable based on their title or prompt.
# Delete empty and throwaway sessions before sorting
from core.database import ChatMessage as DbMsg
db = SessionLocal()
deleted_empty = 0
deleted_throwaway = 0
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
try:
rows_q = db.query(DbSession).filter(DbSession.archived == False)
if user:
@@ -1339,11 +1349,15 @@ def setup_session_routes(
rows_q = rows_q.filter(DbSession.owner == user)
rows = rows_q.limit(2000).all()
folder_map = {r.id: r.folder for r in rows}
# Precompute per-session message counts in one aggregate query
# Precompute per-session message counts in TWO aggregate queries
# instead of 1–3 queries PER session — with many chats the per-row
# loop was doing thousands of round-trips and blowing the timeout.
from sqlalchemy import func as _sa_func
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
_asst_counts = dict(
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
)
cleanup_now = utcnow_naive()
for row in rows:
# Never delete important sessions
@@ -1353,10 +1367,9 @@ def setup_session_routes(
if (row.name or "").strip() == "Incognito":
should_delete = True
deleted_throwaway += 1
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
continue
if is_session_recently_active(row, now=cleanup_now):
continue
@@ -1365,11 +1378,31 @@ def setup_session_routes(
if msg_count == 0:
should_delete = True
deleted_empty += 1
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
# Check first user message content (AI renames sessions, so
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
# Count assistant messages — if user sent something but AI never replied, it's dead
assistant_count = _asst_counts.get(row.id, 0)
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
# Single user message with no AI response = dead session
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
if deleted_empty or deleted_throwaway:
db.commit()
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
+9 -3
View File
@@ -20,7 +20,7 @@ from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
)
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
@@ -475,7 +475,10 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
raise HTTPException(400, "Cron expression is required for cron schedule")
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
if not is_valid_cron(req.cron_expression):
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
raise HTTPException(400, "Invalid cron expression")
if req.trigger_type == "event" and not req.trigger_event:
raise HTTPException(400, "Event name is required for event-triggered tasks")
@@ -785,7 +788,10 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.character_id = req.character_id or None
if req.cron_expression is not None:
if req.cron_expression:
if not is_valid_cron(req.cron_expression):
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
raise HTTPException(400, "Invalid cron expression")
task.cron_expression = req.cron_expression or None
+45 -91
View File
@@ -25,12 +25,11 @@ from src.auth_helpers import effective_user, is_delegated_credential
from src.owner_identity import auth_disabled, is_request_sentinel_owner
from src.attachment_refs import attachment_refs_from_metadata
from src.constants import GENERATED_IMAGES_DIR
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
from src.path_confinement import is_inside
from src.upload_handler import (
UploadCleanupSafetyError,
count_recent_uploads,
extract_upload_ids,
secure_filename,
)
logger = logging.getLogger(__name__)
@@ -154,47 +153,30 @@ def setup_upload_routes(upload_handler):
def _upload_root() -> str:
from src.constants import UPLOAD_DIR
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
def _path_inside_upload_dir(path: str) -> bool:
return is_inside(_upload_root(), path)
def _resolve_upload_path(file_id: str) -> str:
safe_id = secure_filename(os.path.basename(file_id))
if not safe_id:
raise HTTPException(400, "Invalid file ID")
root = _upload_root()
direct = os.path.normpath(os.path.join(root, safe_id))
from src.constants import UPLOAD_DIR
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
direct = os.path.join(upload_root, file_id)
if os.path.lexists(direct):
try:
canonical_direct = confine(root, direct, allow_root=False)
except (PathEscape, ValueError):
if not _path_inside_upload_dir(direct):
raise HTTPException(403, "Access denied")
if os.path.isfile(canonical_direct):
return canonical_direct
if os.path.isfile(direct):
return direct
raise HTTPException(404, "File not found")
info = _load_upload_info(safe_id)
stored_path = (info or {}).get("path")
if stored_path:
try:
canonical_stored = confine(root, stored_path, allow_root=False)
if os.path.isfile(canonical_stored):
return canonical_stored
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
for walk_root, _dirs, files in os.walk(root, followlinks=False):
if safe_id not in files:
for root, _dirs, files in os.walk(upload_root, followlinks=False):
if file_id not in files:
continue
cand = os.path.normpath(os.path.join(walk_root, safe_id))
try:
canonical_cand = confine(root, cand, allow_root=False)
except (PathEscape, ValueError):
path = os.path.join(root, file_id)
if not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
if os.path.isfile(canonical_cand):
return canonical_cand
if os.path.isfile(path):
return path
raise HTTPException(404, "File not found")
raise HTTPException(404, "File not found")
@@ -453,79 +435,52 @@ def setup_upload_routes(upload_handler):
logger.error(f"Failed to get upload stats: {e}")
raise HTTPException(500, "Failed to get upload statistics")
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
@router.get("/{file_id}")
async def download_file(request: Request, file_id: str, thumb: int = 0):
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
a small cached JPEG thumbnail for images (used by chat attachment previews)
so the client isn't downloading the full-resolution photo just to show it tiny."""
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
JPEG thumbnail for images (used by chat attachment previews) so the
client isn't downloading the full-resolution photo just to show it tiny."""
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
safe_file_id = secure_filename(os.path.basename(file_id))
import mimetypes as _mt
# Look up original filename and owner from uploads.json
original_name = safe_file_id
original_name = file_id
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
# back to the .bak sibling, then to {}), so a truncated DB degrades to
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
db = upload_handler._load_upload_index()
info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
if info:
original_name = info.get("name", safe_file_id)
original_name = info.get("name", file_id)
file_owner = info.get("owner") if info else None
_verify_upload_access(request, file_owner)
upload_root = _upload_root()
path = _resolve_upload_path(safe_file_id)
try:
path = confine(upload_root, path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
path = _resolve_upload_path(file_id)
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
from fastapi.responses import FileResponse
# Downscaled thumbnail for image previews — generated once and cached.
# Skip generation during HEAD probe to avoid loading/processing whole image.
if thumb and mime.startswith("image/"):
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
try:
thumb_path = confine(upload_root, thumb_path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
raise HTTPException(403, "Access denied")
if request.method == "HEAD":
if (os.path.isfile(thumb_path)
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
# GET may generate a JPEG or fall back to the original on failure.
# Neither representation is known without decoding the image. HEAD
# must not advertise the original/stale file's headers as a thumb.
from fastapi.responses import Response
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
del response.headers["content-length"]
return response
else:
try:
from PIL import Image, ImageOps
os.makedirs(thumb_dir, exist_ok=True)
if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path)
# iPhone / camera JPEGs encode rotation in EXIF rather than
# the pixel data. Browsers honour that on the original via
# image-orientation:from-image, but PIL strips EXIF when it
# saves the JPEG thumb, leaving the pixels sideways. Bake
# the rotation into the pixels before thumbnailing.
im = ImageOps.exif_transpose(im)
im.thumbnail((320, 320))
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(thumb_path, "JPEG", quality=80)
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
except Exception as e:
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
# Fall through to the full image.
from PIL import Image, ImageOps
thumb_dir = os.path.join(_upload_root(), ".thumbs")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path)
# iPhone / camera JPEGs encode rotation in EXIF rather than
# the pixel data. Browsers honour that on the original via
# image-orientation:from-image, but PIL strips EXIF when it
# saves the JPEG thumb, leaving the pixels sideways. Bake
# the rotation into the pixels before thumbnailing.
im = ImageOps.exif_transpose(im)
im.thumbnail((320, 320))
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(thumb_path, "JPEG", quality=80)
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
except Exception as e:
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
# Fall through to the full image.
return FileResponse(
path,
media_type=mime,
@@ -541,10 +496,9 @@ def setup_upload_routes(upload_handler):
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
def _vision_cache_path(file_id: str) -> str:
safe_id = secure_filename(os.path.basename(file_id))
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
cache_dir = os.path.join(_upload_root(), ".vision")
os.makedirs(cache_dir, exist_ok=True)
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
return os.path.join(cache_dir, file_id + ".txt")
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
"""Copy upload OCR/vision text onto the promoted gallery image row."""
-122
View File
@@ -1,122 +0,0 @@
"""Fail image builds that select an unfixed Debian native Brotli decoder.
Debian Node links libbrotlidec rather than bundling upstream Node's decoder.
Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel
metadata, to establish the package floor for CVE-2026-111497:
https://security-tracker.debian.org/tracker/CVE-2026-111497
"""
import argparse
import ctypes
from pathlib import Path
import platform
import re
import subprocess
import sys
DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"}
DECODER_SONAME = "libbrotlidec.so.1"
def command(*args):
return subprocess.check_output(args, text=True).strip()
def check_package():
release = platform.freedesktop_os_release()
floor = DEBIAN_FIXED.get(release.get("VERSION_ID"))
if release.get("ID") != "debian" or floor is None:
raise ValueError("Brotli package floor is not defined for this distribution")
record = command(
"dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1"
).splitlines()
if len(record) != 2 or any(not value.strip() for value in record):
raise ValueError("invalid libbrotli1 package record: expected status and version")
status, version = record
if status != "install ok installed":
raise ValueError(f"libbrotli1 is not fully installed (status: {status})")
if subprocess.run(
["dpkg", "--compare-versions", version, "ge", floor], check=False
).returncode != 0:
raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}")
owned = {
Path(line).resolve()
for line in command("dpkg-query", "-L", "libbrotli1").splitlines()
if Path(line).name == DECODER_SONAME
}
if not owned:
raise ValueError("libbrotli1 has no packaged native decoder")
return version, owned
def loaded_path(library):
# Inspect the library actually selected by the dynamic loader. An installed
# fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows.
class DlInfo(ctypes.Structure):
_fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p),
("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)]
dladdr = ctypes.CDLL(None).dladdr
dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
dladdr.restype = ctypes.c_int
info = DlInfo()
if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)):
raise ValueError("cannot identify the loaded Brotli decoder")
return Path(info.filename.decode()).resolve()
def check_decoder(library):
# The fix adds error -17. Older decoders return INVALID here, including
# upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same
# marker with different error-string prefixes.
# No malformed attacker-controlled stream is needed for this build check.
library.BrotliDecoderErrorString.argtypes = [ctypes.c_int]
library.BrotliDecoderErrorString.restype = ctypes.c_char_p
if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker")
library.BrotliDecoderDecompress.argtypes = [
ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p
]
library.BrotliDecoderDecompress.restype = ctypes.c_int
# A small, valid uncompressed Brotli stream must still decode correctly.
encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403")
output = ctypes.create_string_buffer(11)
size = ctypes.c_size_t(len(output))
result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output)
if result != 1 or size.value != 11 or output.raw != b"hello world":
raise ValueError("native Brotli decoder failed the valid-stream control")
def check_node(node, decoder):
# ldd inspects the distro executable and its libnode dependency without
# executing JavaScript or relying on process.versions.brotli.
linked = command("ldd", str(node))
match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked)
if not match or Path(match[1]).resolve() != decoder:
raise ValueError(f"{node} does not select the checked Debian Brotli decoder")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder")
args = parser.parse_args(argv)
try:
version, owned = check_package()
library = ctypes.CDLL(DECODER_SONAME)
decoder = loaded_path(library)
if decoder not in owned:
raise ValueError(f"selected decoder is outside libbrotli1: {decoder}")
check_decoder(library)
if args.node:
check_node(args.node, decoder)
except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc:
print(f"Brotli acceptance failed: {exc}", file=sys.stderr)
return 1
print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -951,12 +951,14 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
+2 -9
View File
@@ -6,7 +6,6 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
album, and surfaces EXIF metadata.
odysseus-gallery list --limit 20 --favorites
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
odysseus-gallery show IMAGE_ID
odysseus-gallery albums | jq -r '.[] | .name'
odysseus-gallery search "sunset" # matches prompt + tags
@@ -84,13 +83,7 @@ def cmd_list(args):
fail(f"no album named {args.album!r}")
q = q.filter(GalleryImage.album_id == al.id)
if args.tag:
for tag in (value.strip() for value in args.tag.split(",")):
if not tag:
continue
q = q.filter(
GalleryImage.tags.ilike(f"%{tag}%") |
GalleryImage.ai_tags.ilike(f"%{tag}%")
)
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
emit([_serialize_image(i) for i in q.all()], args)
finally:
@@ -166,7 +159,7 @@ def _build_parser():
pl.add_argument("--limit", type=int, default=50)
pl.add_argument("--favorites", action="store_true")
pl.add_argument("--album", help="filter by album name")
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
pl.add_argument("--tag", help="substring match against tags column")
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
+6 -12
View File
@@ -43,7 +43,7 @@ import logging
import os
import re
import sys
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
# Anchor the path so the script works when invoked from anywhere.
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
emit([], args)
return
all_uids = data[0].split()
# Keep a bounded arrival batch; order its decoded rows by Date below.
# Newest first, limit
uids = list(reversed(all_uids))[: args.limit]
out = []
for uid in uids:
@@ -165,15 +165,10 @@ def cmd_list(args) -> None:
sender_name, sender_addr = parseaddr(sender)
date_raw = msg.get("Date", "")
try:
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
iso = parsed_date.isoformat() if parsed_date else ""
if parsed_date is not None and parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
except Exception:
iso = ""
date_epoch = float("-inf")
out.append((date_epoch, {
out.append({
"uid": uid.decode(),
"date": iso,
"from_addr": sender_addr,
@@ -181,11 +176,10 @@ def cmd_list(args) -> None:
"subject": subject,
"is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags,
}))
})
except Exception as e:
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
out.sort(key=lambda item: item[0], reverse=True)
emit([row for _, row in out], args)
emit(out, args)
# ─── read ────────────────────────────────────────────────────────────
-336
View File
@@ -1,336 +0,0 @@
#!/usr/bin/env python3
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
Structured, format-aware fail-closed security:
Replaces naive regex-based replacement with structured JSON / HAR parsing,
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
safe source code masking, and strict fail-closed purge on corruption or error.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
import urllib.parse
import zipfile
from typing import Any, Dict, List, Set
logger = logging.getLogger(__name__)
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
SENSITIVE_KEY_PATTERN = re.compile(
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
)
SENSITIVE_PARAM_NAMES = frozenset({
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
"signature", "client_secret", "credential"
})
SENSITIVE_HEADER_NAMES = frozenset({
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
"api-key", "x-auth-token", "x-access-token"
})
def sanitize_url(url: str) -> str:
"""Sanitize query parameters and authority credentials in URLs."""
if not isinstance(url, str) or not url.strip():
return url
try:
parts = urllib.parse.urlsplit(url)
except Exception:
return url
netloc = parts.netloc
if "@" in netloc:
userinfo, host = netloc.split("@", 1)
if ":" in userinfo:
user, _ = userinfo.split(":", 1)
netloc = f"{user}:[REDACTED]@{host}"
else:
netloc = f"[REDACTED]@{host}"
query = parts.query
if query:
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
sanitized_pairs = []
for k, v in q_pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
sanitized_pairs.append((k, "[REDACTED]"))
else:
sanitized_pairs.append((k, v))
query = urllib.parse.urlencode(sanitized_pairs)
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
def sanitize_body_text(text: str) -> str:
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
if not isinstance(text, str) or not text.strip():
return text
# 1. Try JSON
trimmed = text.strip()
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
try:
parsed = json.loads(trimmed)
sanitized = sanitize_structured_obj(parsed)
return json.dumps(sanitized, ensure_ascii=False)
except Exception:
pass
# 2. Try URL-encoded form data
if "=" in trimmed and not trimmed.startswith("<"):
try:
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
out_pairs = []
for k, v in pairs:
k_lower = k.lower().replace("-", "_")
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
out_pairs.append((k, "[REDACTED]"))
else:
out_pairs.append((k, v))
return urllib.parse.urlencode(out_pairs)
except Exception:
pass
return text
def sanitize_structured_obj(obj: Any) -> Any:
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
if isinstance(obj, dict):
new_dict: Dict[str, Any] = {}
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
name_val = obj.get("name")
val_val = obj.get("value")
if isinstance(name_val, str) and isinstance(val_val, str):
name_lower = name_val.lower().strip()
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
for k, v in obj.items():
if k == "value":
new_dict[k] = "[REDACTED]"
else:
new_dict[k] = sanitize_structured_obj(v)
return new_dict
# Process standard dictionary keys
for k, v in obj.items():
k_lower = str(k).lower().replace("-", "_")
# Action parameters in Playwright traces (e.g. page.fill, page.type)
if k == "params" and isinstance(v, dict):
sanitized_params = sanitize_structured_obj(v)
# If this action involves fill, type, or sensitive inputs, redact value/text
if "value" in sanitized_params:
sanitized_params["value"] = "[REDACTED]"
if "text" in sanitized_params:
sanitized_params["text"] = "[REDACTED]"
new_dict[k] = sanitized_params
continue
# DOM snapshot nodes
if k == "attributes" and isinstance(v, (dict, list)):
if isinstance(v, dict):
attr_dict = dict(v)
type_val = str(attr_dict.get("type") or "").lower()
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
if "value" in attr_dict:
attr_dict["value"] = "[REDACTED]"
new_dict[k] = attr_dict
continue
elif isinstance(v, list):
# List of attribute strings or pairs
new_list = []
is_pw = any("password" in str(x).lower() for x in v)
for x in v:
if is_pw and isinstance(x, str) and not x.startswith("type="):
new_list.append("[REDACTED]")
else:
new_list.append(sanitize_structured_obj(x))
new_dict[k] = new_list
continue
# Request postData / text
if k in ("postData", "postDataText") and isinstance(v, str):
new_dict[k] = sanitize_body_text(v)
continue
# URL fields
if k == "url" and isinstance(v, str):
new_dict[k] = sanitize_url(v)
continue
# Cookie arrays
if k == "cookies" and isinstance(v, list):
sanitized_cookies = []
for c in v:
if isinstance(c, dict):
c_copy = dict(c)
if "value" in c_copy:
c_copy["value"] = "[REDACTED]"
sanitized_cookies.append(c_copy)
else:
sanitized_cookies.append(sanitize_structured_obj(c))
new_dict[k] = sanitized_cookies
continue
# Direct sensitive key matches
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
new_dict[k] = "[REDACTED]"
continue
new_dict[k] = sanitize_structured_obj(v)
return new_dict
elif isinstance(obj, list):
return [sanitize_structured_obj(item) for item in obj]
elif isinstance(obj, str):
# Check for inline credentials/tokens
if obj.startswith("Bearer ") or obj.startswith("bearer "):
return "Bearer [REDACTED]"
if obj.startswith("Basic ") or obj.startswith("basic "):
return "Basic [REDACTED]"
return obj
return obj
def sanitize_jsonl_content(data: bytes) -> bytes:
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
lines = data.split(b"\n")
out_lines = []
for line in lines:
if not line.strip():
out_lines.append(line)
continue
try:
parsed = json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
# Not valid JSON, keep as-is
out_lines.append(line)
continue
sanitized = sanitize_structured_obj(parsed)
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
return b"\n".join(out_lines)
def sanitize_trace_archive(zp: str) -> bool:
"""Sanitize a single zip archive in a strict fail-closed manner.
If the archive is malformed, partial, unreadable, or encounters an exception
during processing, the archive is permanently deleted so that raw traces
cannot leak.
Returns True if successfully sanitized, False if deleted due to error.
"""
if not os.path.exists(zp):
return False
temp_zp = zp + ".tmp"
try:
if os.path.getsize(zp) == 0:
raise zipfile.BadZipFile("Empty archive")
with zipfile.ZipFile(zp, "r") as zin:
infolist = zin.infolist()
if not infolist:
raise zipfile.BadZipFile("Archive has no entries")
corrupt_file = zin.testzip()
if corrupt_file is not None:
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for item in infolist:
filename_lower = item.filename.lower()
# 1. Binary image screenshots/diagnostics: preserve as-is
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
content = zin.read(item.filename)
zout.writestr(item, content)
continue
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
# Mask safely without syntax corruption!
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
zout.writestr(item, safe_placeholder)
continue
# 3. JSON Lines and structured JSON/HAR traces
content = zin.read(item.filename)
if filename_lower.endswith((".trace", ".network", ".stacks")):
sanitized_content = sanitize_jsonl_content(content)
zout.writestr(item, sanitized_content)
elif filename_lower.endswith((".json", ".har")):
try:
parsed = json.loads(content.decode("utf-8"))
sanitized = sanitize_structured_obj(parsed)
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
except Exception:
# If invalid JSON, treat as text line-by-line
zout.writestr(item, sanitize_jsonl_content(content))
else:
# Unknown binary or text member: exclude to fail closed
continue
# Atomic replace
os.replace(temp_zp, zp)
return True
except Exception as exc:
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
for path_to_remove in (temp_zp, zp):
if os.path.exists(path_to_remove):
try:
os.remove(path_to_remove)
except OSError as remove_err:
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
return False
def sanitize_directory(results_dir: str) -> int:
"""Walk results_dir and sanitize every .zip archive fail-closed.
Returns the number of archives processed. Exits with 1 if any archive fails.
"""
if not os.path.exists(results_dir):
return 0
count = 0
all_ok = True
for root, _, files in os.walk(results_dir):
for f in files:
if f.endswith(".zip"):
zp = os.path.join(root, f)
ok = sanitize_trace_archive(zp)
if not ok:
all_ok = False
count += 1
if not all_ok:
sys.exit(1)
return count
def main() -> None:
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <results_directory>")
sys.exit(1)
target_dir = sys.argv[1]
sanitize_directory(target_dir)
if __name__ == "__main__":
main()
+6 -36
View File
@@ -11,38 +11,6 @@ import time
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _stop_owned_server(server, identity):
"""Close only identity-bound descendants, including MCP's new sessions.
Capture before signalling the parent: an orphaned stderr holder can no
longer be attributed by ancestry. Never search by executable name or kill
the caller's process group.
"""
from src import process_lifecycle as lifecycle
descendants = (lifecycle.bind_descendants([identity.pid])
if identity.verdict() == lifecycle.OWNED else [])
if os.name == "nt":
if identity.verdict() == lifecycle.OWNED:
lifecycle.taskkill_tree(identity.pid)
else:
outcome = lifecycle.terminate_identities(
[*(entry.identity for entry in reversed(descendants)), identity],
steps=lifecycle.term_kill_steps(10),
)
if outcome.survivors or outcome.unverified:
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
try:
logs, _ = server.communicate(timeout=2)
except subprocess.TimeoutExpired as error:
# Do not turn an unidentified pipe holder into an unbounded wait.
server.stdout.close()
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
return logs
def main():
@@ -68,10 +36,7 @@ def main():
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=(os.name != "nt"),
)
from src.process_lifecycle import ProcessIdentity
server_identity = ProcessIdentity.capture(server.pid)
result = None
try:
origin = f"http://127.0.0.1:{port}"
@@ -97,7 +62,12 @@ def main():
print(result.stderr, file=sys.stderr)
return result.returncode
finally:
logs = _stop_owned_server(server, server_identity)
server.terminate()
try:
logs, _ = server.communicate(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
logs, _ = server.communicate()
if result is None or result.returncode:
print(logs[-8000:], file=sys.stderr)
+2 -20
View File
@@ -202,35 +202,17 @@ def _infer_use_case(repo_id, pipeline_tag):
return "general"
# HF collections mix in models no Cookbook LLM backend can serve (vision
# encoders, time-series forecasters, image/video generators). Image models
# have their own registry, so keep these out of the LLM catalog.
_NON_LLM_PIPELINE_TAGS = frozenset({
"time-series-forecasting",
"zero-shot-image-classification",
"image-classification",
"image-feature-extraction",
"text-to-image",
"image-to-video",
"text-to-video",
})
def _entry_from_collection_item(collection, item, source):
repo_id = item.get("id") or ""
if item.get("type") != "model" or not repo_id:
return None
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
return None
repo_prefix = source.get("repo_prefix")
if repo_prefix and not repo_id.startswith(repo_prefix):
return None
raw_params = item.get("numParameters") or 0
# Read the name even when HF gives a total: it is the only source of the
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
name_params, active = _parse_params_from_name(repo_id)
active = None
if not raw_params:
raw_params = name_params
raw_params, active = _parse_params_from_name(repo_id)
param_label, raw_params = _format_params(raw_params)
if not raw_params:
return None
+3 -18
View File
@@ -628,14 +628,6 @@ async def extract_and_store(
if not fact_text or len(fact_text) < 5:
continue
# A fuller owner-specific name must not rewrite a shared legacy
# entry, but that less-specific entry must not suppress it either.
legacy_refinements = {
id(entry) for entry in existing
if _owner and entry.get("owner") is None
and _name_enrichment_target(fact_text, [entry]) is entry
}
# Dedup: check vector similarity first (fast), then exact text match.
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
# remote endpoint down) must not abort the whole batch — fall through
@@ -657,25 +649,18 @@ async def extract_and_store(
# silently dropped. Mirror the owner predicate used by the
# text dedup below; cross-tenant/stale matches fall through.
_match = next((e for e in existing if e.get("id") == existing_id), None)
if (_match is not None and id(_match) not in legacy_refinements
and (_match.get("owner") == _owner or _match.get("owner") is None)):
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
continue
# Text dedup fallback: exact match + fuzzy similarity
user_existing = [e for e in existing
if (e.get("owner") == _owner or e.get("owner") is None)
and id(e) not in legacy_refinements] if _owner else existing
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
# Name enrichment: if an existing name fact is a strict prefix of
# this more complete one, upgrade it in place instead of adding a
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
# Legacy unowned entries may be visible to multiple owners; never
# rewrite one with a private fact from the current owner.
_enrich = _name_enrichment_target(
fact_text, [e for e in user_existing if e.get("owner") == _owner]
)
_enrich = _name_enrichment_target(fact_text, user_existing)
if _enrich is not None:
_enrich["text"] = fact_text
_enrich["timestamp"] = int(time.time())
+2 -6
View File
@@ -84,14 +84,10 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
def _parse_scalar(raw: str) -> Any:
raw = raw.strip()
if raw == "":
return ""
# String fields keep their spelling; quoted values still use the decoder
# below so existing escapes round-trip as before.
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
return raw
if raw.startswith("[") and raw.endswith("]"):
inner = raw[1:-1].strip()
if not inner:
@@ -175,7 +171,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
pending_key = key
fm[key] = []
else:
fm[key] = _parse_scalar(val, string_only=(key == "version"))
fm[key] = _parse_scalar(val)
pending_key = None
continue
m2 = _FM_BLOCK_LIST_RE.match(line)
+140 -155
View File
@@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "list_tools"})
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
@@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
"manage_notes": """\
```manage_notes
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
@@ -21061,103 +21061,6 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_author
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
disabled = set(disabled_tools or ())
from src.spec_router import latest_decision
_spec_decision = latest_decision() or {}
_spec_choice = _spec_decision.get("choice")
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
and decided and _spec_choice == "answer_directly")
try:
_mgr = get_mcp_manager()
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
except Exception:
mcp_schemas = []
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
if _spec_choice == "ask_user":
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
# can act when the detail is there or ask when it is not.
_top = _spec_decision.get("top") or []
if _top:
picks = set(picks) | {_top[0][0]}
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
base_offered = set(turn_contract.offered)
# The router sees the full catalog, while this request's policy may
# deny a tool (for example, a browser turn with Web access switched
# off). Preserve that conflict as an unavailable contract. Keeping the
# old empty menu instead lets the model invent a tool result in prose.
_top = _spec_decision.get("top") or []
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
if _primary and f"mcp__email__{_primary}" in mcp_names:
_primary = f"mcp__email__{_primary}"
# A policy denial is surfaced even if the router chose outside the
# base inventory; it never grants access to that tool.
def _denied(name):
return (name in disabled
or (tool_policy is not None and (
tool_policy.blocks(name)
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
_unavailable_pick = ""
if _primary in picks and _denied(_primary):
_unavailable_pick = _primary
elif picks and all(_denied(name) for name in picks):
_unavailable_pick = sorted(picks)[0]
if _unavailable_pick:
blocked = replace(
turn_contract, required=frozenset(), offered=frozenset(),
unavailable=frozenset({_unavailable_pick}), schema_json=(),
required_read_operation=None, routing_experiment="spec-policy-denied",
)
_spec_active_contract.set(blocked)
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
return blocked
want = set(picks) - disabled
# Always-on basics only when the base turn offers them, so the Spec
# arm never offers a tool the base arm would not.
if not _spec_direct:
want |= {"ask_user", "update_plan"} & base_offered
if not (strict and decided):
want |= base_offered
want &= base_offered
if not want:
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
sorted(picks), sorted(base_offered))
return turn_contract
offer = want & base_offered & set(turn_contract.executable)
# A router may narrow the menu only when every existing obligation
# remains satisfiable. It cannot redefine the user's requested work.
required_read = getattr(turn_contract, "required_read_operation", None)
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
or (required_read is not None and required_read.tool not in offer)):
return turn_contract
by_name = {}
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
nm = (sc.get("function") or {}).get("name")
if nm in offer and nm not in by_name:
by_name[nm] = sc
for sc in turn_contract.schemas():
nm = (sc.get("function") or {}).get("name")
if nm in offer:
by_name[nm] = sc
if not by_name and not _spec_direct:
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
return turn_contract
# Required tools and the required read carry over unchanged: harness
# completion logic keys on them (e.g. a calendar list is a read, not an
# effect that may end with "Done."). If one lost its schema, contract
# validation fails and the original contract is retained below.
new = replace(
turn_contract, offered=frozenset(by_name),
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
routing_experiment="spec-router")
_spec_active_contract.set(new)
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
sorted(by_name), sorted(turn_contract.offered), decided)
return new
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=None):
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
@@ -21166,17 +21069,10 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
Runs before any runtime dispatch so the compact preview runtime and the
main loop both serve the router's set; only the offered tools change.
"""
# Contexts can be inherited by a child task; a turn must never inherit a
# previous turn's decision when routing is disabled or unavailable.
from src import spec_router as _router_context
if hasattr(_router_context, "clear_decision"):
_router_context.clear_decision()
if (turn_contract is None
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
return turn_contract
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
return turn_contract
try:
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
return turn_contract
@@ -21184,38 +21080,105 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
return turn_contract
try:
from src.spec_router import spec_route_tools as _spec_route_tools_early
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
disabled = set(disabled_tools or ())
picks, decided = _spec_route_tools_early(
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
except Exception as exc:
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
return turn_contract
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=None):
"""Asynchronous non-blocking turn contract routing."""
from src import spec_router as _router_context
if hasattr(_router_context, "clear_decision"):
_router_context.clear_decision()
if (turn_contract is None
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
return turn_contract
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
return turn_contract
try:
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
from src.spec_router import LAST_DECISIONS as _spec_decisions
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
and decided and _spec_choice == "answer_directly")
try:
_mgr = get_mcp_manager()
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
except Exception:
mcp_schemas = []
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
if _spec_choice == "ask_user":
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
# can act when the detail is there or ask when it is not.
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
if _top:
picks = set(picks) | {_top[0][0]}
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
base_offered = set(turn_contract.offered)
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
# The router sees the full catalog, while this request's policy may
# deny a tool (for example, a browser turn with Web access switched
# off). Preserve that conflict as an unavailable contract. Keeping the
# old empty menu instead lets the model invent a tool result in prose.
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
if _primary and f"mcp__email__{_primary}" in mcp_names:
_primary = f"mcp__email__{_primary}"
# `executable` belongs to the base contract. In subset=0, Spec may
# legitimately add a policy-permitted tool that the base inventory did
# not include. Only an explicit request-policy denial blocks the pick.
def _denied(name):
return (name in disabled
or (tool_policy is not None and (
tool_policy.blocks(name)
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
_unavailable_pick = ""
if _primary in picks and _denied(_primary):
_unavailable_pick = _primary
elif picks and all(_denied(name) for name in picks):
_unavailable_pick = sorted(picks)[0]
if _unavailable_pick:
blocked = replace(
turn_contract, required=frozenset(), offered=frozenset(),
unavailable=frozenset({_unavailable_pick}), schema_json=(),
required_read_operation=None, routing_experiment="spec-policy-denied",
)
_spec_active_contract.set(blocked)
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
return blocked
want = set(picks) - disabled
# Always-on basics only when the base turn offers them, so the Spec
# arm never offers a tool the base arm would not.
if not _spec_direct:
want |= {"ask_user", "update_plan"} & base_offered
if not (strict and decided):
want |= base_offered
if subset_of_base:
# A/B control: Spec offered set is a subset of the base offered set
# (measures narrowing only). Empty intersection keeps the base set.
want &= base_offered
if not want:
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
sorted(picks), sorted(base_offered))
return turn_contract
offer = want if strict else (want & set(turn_contract.executable))
by_name = {}
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
nm = (sc.get("function") or {}).get("name")
if nm in offer and nm not in by_name:
by_name[nm] = sc
for sc in turn_contract.schemas():
nm = (sc.get("function") or {}).get("name")
if nm in offer:
by_name[nm] = sc
if not by_name and not _spec_direct:
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
return turn_contract
except Exception:
return turn_contract
try:
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
picks, decided = await _spec_route_tools_early_async(
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
# Keep the contract's required tools and required read whenever they
# are still offered: harness completion logic keys on them (e.g. a
# calendar list is a read, not an effect that may end with "Done.").
_rro = getattr(turn_contract, "required_read_operation", None)
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
new = replace(
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
offered=frozenset(by_name),
executable=frozenset(set(turn_contract.executable) | set(by_name)),
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
_spec_active_contract.set(new)
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
sorted(by_name), sorted(turn_contract.offered), decided)
return new
except Exception as exc:
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
return turn_contract
@@ -21300,11 +21263,20 @@ async def stream_agent_loop(
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
and not (tool_policy and tool_policy.block_all_tool_calls)
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
yield 'data: [DONE]\n\n'
from src.spec_driver import stream_spec_turn
async for chunk in stream_spec_turn(
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
client_runtime_context=client_runtime_context,
external_untrusted_context_seen=external_untrusted_context_seen,
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
temperature=0.0, request_authority=request_authority,
):
yield chunk
return
turn_contract = await _spec_router_contract_async(
turn_contract = _spec_router_contract(
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=tool_policy)
if (turn_contract is not None
@@ -21317,20 +21289,6 @@ async def stream_agent_loop(
yield 'data: [DONE]\n\n'
return
try:
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
except Exception:
_spec_turn = False
# Without a turn contract (scheduled, skill and monitor runs) no Spec
# routing applies; the turn runs on the compact surface as usual.
if _spec_turn and _blocks_before_inference(turn_contract):
unavailable = ", ".join(sorted(turn_contract.unavailable))
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
yield 'data: [DONE]\n\n'
return
if is_compact_preview_contract(turn_contract):
from src.clean_agent_preview import stream_preview
async for chunk in stream_preview(
@@ -21356,9 +21314,26 @@ async def stream_agent_loop(
yield chunk
return
_spec_turn = False
try:
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
except Exception:
_spec_turn = False
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
# Ajax Spec: the router, not the regex turn contract, decides the tools.
# Drop the contract here and in the dispatcher's context so neither the
# route build nor execute_tool_block enforces its offered set.
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
turn_contract = None
try:
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
_spec_active_contract.set(None)
except Exception as _exc:
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
if turn_contract is not None:
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
if _blocks_before_inference(turn_contract):
if not _spec_turn and _blocks_before_inference(turn_contract):
unavailable = ", ".join(sorted(turn_contract.unavailable))
clarification = (
"I can’t perform this request with the currently permitted tools "
@@ -24361,7 +24336,7 @@ async def stream_agent_loop(
# "spec" = compact tools plus a small-model router; the harness-facing
# surface already reads "compact", so detect spec from the raw mode.
try:
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
_spec_surface = _configured_model_tool_surface_raw(
candidate_url, candidate_model, owner, headers=candidate_headers,
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
except Exception:
@@ -24435,7 +24410,7 @@ async def stream_agent_loop(
if _spec_mode == "strict" and _spec_decided:
# The router decides which tools are injected: its pick(s),
# tools already used in this chat, and the always-on basics.
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
if _spec_strict:
route_tools = _spec_strict
_spec_offered = frozenset(route_tools)
@@ -25386,6 +25361,15 @@ async def stream_agent_loop(
if (schema.get("function", {}).get("name") or schema.get("name"))
in _spec_names
]
_have = {
(schema.get("function", {}).get("name") or schema.get("name"))
for schema in contract_schemas
}
contract_schemas += [
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
and schema.get("function", {}).get("name") not in disabled_tools
]
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
if route_state["is_api_model"]:
if tool_surface == "full":
@@ -26337,7 +26321,7 @@ async def stream_agent_loop(
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
if round_num == 1:
try:
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
except Exception:
pass
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
@@ -33941,6 +33925,7 @@ async def stream_agent_loop(
"mutation_epoch": _workspace_mutation_epoch,
"error": _failure_text[:600],
}
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
logger.info(
"[agent] recorded failed call %s in round %s at mutation epoch %s",
_call_signature,
+13 -35
View File
@@ -585,9 +585,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key):
# Tool policy is not a general setting, even if a future default
# is added: resetting it must never re-enable disabled tools.
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = {
"image_quality": ["low", "medium", "high"],
@@ -715,35 +713,13 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
save_settings(s)
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
elif action == "enable_tool":
# The global disabled_tools denylist is the one hard permission
# boundary the Settings -> Agent Tools panel offers admins. It
# must only ever be *narrowed* from inside an agent turn: a
# model that can call enable_tool can undo the boundary itself,
# and any web page, email, or document the agent reads can ask
# it to (prompt injection re-enabling bash/email/etc.). Widening
# the tool set stays a human action in the Settings panel.
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
return {
"error": (
f"Re-enabling tools is not available from chat"
f"{f' (requested: {tool_name})' if tool_name else ''}. "
"Disabled tools are an admin policy: ask the user to turn the tool "
"back on in Settings > Agent Tools. You can still disable tools "
"(disable_tool) or list the current state (list_tools)."
),
"exit_code": 1,
}
elif action in ("disable_tool", "list_tools"):
elif action in ("disable_tool", "enable_tool", "list_tools"):
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> browser tools,
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc. Only disable_tool mutates: see the
# enable_tool branch above for why the agent can never widen
# the denylist.
# manage_memory, etc.
from src.settings import get_setting, save_settings, load_settings
_ALIASES = {
"shell": ["bash"],
@@ -777,8 +753,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"response": (
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
"Common toggles: shell (bash), search (web_search), browser, documents, "
"memory, skills, images, tasks, notes, calendar, email. "
"To re-enable a tool, open Settings > Agent Tools."
"memory, skills, images, tasks, notes, calendar, email."
),
"disabled": list(current),
"exit_code": 0,
@@ -792,19 +767,22 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
settings = load_settings()
current = list(settings.get("disabled_tools") or [])
before = set(current)
for t in targets:
if t not in current:
current.append(t)
if action == "disable_tool":
for t in targets:
if t not in current:
current.append(t)
else: # enable_tool
current = [t for t in current if t not in targets]
after = set(current)
settings["disabled_tools"] = current
save_settings(settings)
verb = "Disabled" if action == "disable_tool" else "Enabled"
changed = sorted(after.symmetric_difference(before))
return {
"response": (
f"Disabled {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}. "
"Re-enabling is done by the user in Settings > Agent Tools."
f"{verb} {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}."
),
"changed": changed,
"disabled": list(current),
+17 -68
View File
@@ -78,16 +78,7 @@ def _parse(content: Any) -> Dict[str, Any]:
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
# Explicit editor session bound in context is authoritative;
# otherwise fallback to caller/tool args, then default.
# Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session.
return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default")
def _incognito(ctx: Dict[str, Any]) -> bool:
runtime = ctx.get("client_runtime_context")
state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None
return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True)
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
@@ -116,26 +107,17 @@ def _data_url_bytes(data_url: str) -> bytes:
return base64.b64decode(b64)
def _image_reference_path(value: str) -> bool:
# Never use the internal tool credential to fetch an arbitrary app route.
generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value)
upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value)
return bool(generated or upload)
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
from core.database import GalleryImage, SessionLocal
from src.constants import GENERATED_IMAGES_DIR
db = SessionLocal()
image_path = None
try:
root = Path(GENERATED_IMAGES_DIR).resolve()
root.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
image_path = root / filename
image_path.write_bytes(image_bytes)
(root / filename).write_bytes(image_bytes)
new_id = str(uuid.uuid4())
width = height = 0
try:
@@ -181,11 +163,6 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
except Exception:
db.rollback()
if image_path is not None:
try:
image_path.unlink(missing_ok=True)
except OSError:
logger.warning("Failed to remove uncommitted editor image", exc_info=True)
raise
finally:
db.close()
@@ -221,7 +198,7 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
}
try:
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
return bool(data.get("ok") is True and data.get("completed", True) is not False)
return data.get("ok") is not False
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor layer placement failed: %s", exc)
return False
@@ -390,46 +367,28 @@ class RunEditorCommandTool:
args = _parse(content)
op = str(args.get("op") or "").strip()
if not op:
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
params = args.get("params") if isinstance(args.get("params"), dict) else {}
wait = bool(args.get("wait", True))
payload = {
"session": _session(args, ctx),
"op": op,
"params": params,
"wait": wait,
"wait": bool(args.get("wait", True)),
"timeout": float(args.get("timeout") or 60),
}
try:
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
except Exception as exc:
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
is_queued = bool(data.get("queued")) or data.get("status") == "queued"
if is_queued and (not wait or not data.get("ok")):
summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False)
return f"run_editor_command: {op}", {
"output": summary,
"queued": data.get("queued"),
"status": "queued",
"pending": True,
"completed": False,
"ok": False,
"exit_code": 1,
}
ok = bool(data.get("ok") is True and data.get("completed", True) is not False)
status = data.get("status") or ("completed" if ok else "failed")
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
ok = data.get("ok")
result = data.get("result")
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
return f"run_editor_command: {op}", {
"output": summary[:4000],
"result": result,
"screenshot": data.get("screenshot"),
"status": status,
"completed": ok,
"ok": ok,
"exit_code": 0 if ok else 1,
"exit_code": 0 if ok is not False else 1,
}
@@ -483,14 +442,10 @@ class AiEditImageTool:
image = _as_data_url(args.get("image"))
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
if not _image_reference_path(args["image"]):
return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1}
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
resp.raise_for_status()
if not resp.headers.get("content-type", "").lower().startswith("image/"):
return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1}
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
except Exception:
image = None
@@ -595,13 +550,10 @@ class AiEditImageTool:
"model": data.get("model") or model_choice or "",
"exit_code": 0,
}
if _incognito(ctx):
info["image_url"] = data_url
else:
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
except Exception as exc:
logger.warning("ai_edit_image persist failed: %s", exc)
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
except Exception as exc:
logger.warning("ai_edit_image persist failed: %s", exc)
_t["persist"] = time.perf_counter()
if bool(args.get("place", True)):
placed = await _place_layer(
@@ -674,13 +626,10 @@ class GenerateImageLayerTool:
"model": data.get("image_model") or str(args.get("model") or ""),
"exit_code": 0,
}
if _incognito(ctx):
info["image_url"] = data_url
else:
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
except Exception as exc:
logger.warning("generate_image persist failed: %s", exc)
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
except Exception as exc:
logger.warning("generate_image persist failed: %s", exc)
if bool(args.get("place", True)):
placed = await _place_layer(
data_url,
+8 -8
View File
@@ -1207,7 +1207,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
Line 1: prompt describing the image
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
Line 3: size (optional, defaults to 1024x1024)
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
"""
import base64
import httpx
@@ -1225,13 +1225,13 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
prompt = str(args.get('prompt') or '').strip()
model_spec = str(args.get('model') or '').strip()
size = str(args.get('size') or '1024x1024')
quality = str(args.get('quality') or '')
quality = str(args.get('quality') or 'medium')
else:
lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else ""
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
if not prompt:
return {"error": "Image prompt is required (line 1)"}
@@ -1249,8 +1249,8 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
# Use admin-configured model/quality if not specified by the tool call
if not model_spec:
model_spec = _settings.get("image_model", "")
if not quality:
quality = _settings.get("image_quality") or "medium"
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
# Auto-detect best available image model if still not set
if not model_spec:
@@ -1482,7 +1482,7 @@ async def do_edit_image(
session_id: Optional[str] = None,
owner: Optional[str] = None,
size: str = "auto",
quality: Optional[str] = None,
quality: str = "medium",
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
incognito: bool = False,
) -> Dict:
@@ -1509,8 +1509,8 @@ async def do_edit_image(
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality is None:
quality = _settings.get("image_quality") or "medium"
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
if not model_spec:
return {"error": "No image model selected for image editing"}
-5
View File
@@ -211,15 +211,10 @@ class ChatHandler:
from src.settings import get_setting
vision_enabled = get_setting("vision_enabled", True)
if vision_enabled:
from src.owner_identity import auth_disabled
sess_owner = getattr(sess, "owner", None)
if not sess_owner and not auth_disabled():
sess_owner = ""
main_is_vision = await asyncio.to_thread(
model_supports_vision,
sess.model or "",
getattr(sess, "endpoint_url", "") or "",
sess_owner,
)
if effective_att_ids and vision_enabled:
+24 -243
View File
@@ -5,14 +5,9 @@ import re
import os
import json
import time
import socket
import ipaddress
import logging
import httpx
import typing
import httpcore
from httpcore._backends.sync import SyncBackend, SyncStream
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
from urllib.parse import urlparse
from fastapi import HTTPException
from fastapi import UploadFile
@@ -22,9 +17,6 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
logger = logging.getLogger(__name__)
_ORIGINAL_HTTPX_GET = httpx.get
_ORIGINAL_HTTPX_POST = httpx.post
def extract_urls(text: str) -> List[str]:
"""Extract URLs from text using regex pattern."""
@@ -109,167 +101,23 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
_lmstudio_models_cache: dict = {}
_BLOCKED_METADATA_IPS = {
ipaddress.ip_address("169.254.169.254"),
ipaddress.ip_address("169.254.169.253"),
ipaddress.ip_address("100.100.100.200"),
}
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
if ip in _BLOCKED_METADATA_IPS:
return False
if ip.is_link_local:
return False
# Explicit loopback check before reserved rejection:
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
if ip.is_loopback:
return True
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
return False
if ip.is_private:
return True
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
return True
return False
def _is_safe_local_host(host: Optional[str]) -> bool:
"""True for loopback/LAN/Tailscale hosts.
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
unresolved single-label names, and public domains.
"""
host = (host or "").strip().lower()
def _is_local_host(host: Optional[str]) -> bool:
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
host = (host or "").lower()
if not host:
return False
# Strip optional IPv6 square brackets: [::1] -> ::1
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
return True
try:
ip = ipaddress.ip_address(literal)
return _is_ip_safe_local(ip)
ip = ipaddress.ip_address(host)
except ValueError:
pass
allowed_names = {"localhost", "host.docker.internal"}
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
return False
try:
infos = socket.getaddrinfo(host, None)
if not infos:
return False
resolved_ips = []
for info in infos:
sockaddr = info[4]
ip_str = str(sockaddr[0]).split("%")[0]
resolved_ips.append(ipaddress.ip_address(ip_str))
if not resolved_ips:
return False
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
except Exception:
return False
return "." not in host
if ip.is_loopback or ip.is_private or ip.is_link_local:
return True
return ip in ipaddress.ip_network("100.64.0.0/10")
_is_local_host = _is_safe_local_host
class SafeProbeBackend(SyncBackend):
"""Enforces SSRF address policies at TCP connection time.
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
"""
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.NetworkStream:
source_address = None if local_address is None else (local_address, 0)
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
with map_exceptions(exc_map):
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
if not infos:
raise ConnectError(f"Host {host} did not resolve")
for family, socktype, proto, _canon, sockaddr in infos:
ip_str = str(sockaddr[0]).split("%")[0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
raise ConnectError(f"Invalid resolved IP: {ip_str}")
if not _is_ip_safe_local(ip):
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
last_err: Optional[OSError] = None
for family, socktype, proto, _canon, sockaddr in infos:
sock = socket.socket(family, socktype, proto)
try:
if timeout is not None:
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
if socket_options:
for opt in socket_options:
sock.setsockopt(*opt)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
return SyncStream(sock)
except OSError as e:
last_err = e
sock.close()
if last_err is not None:
raise last_err
raise ConnectError(f"Could not connect to {host}:{port}")
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
if httpx.get is not _ORIGINAL_HTTPX_GET:
try:
if headers:
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
return httpx.get(url, timeout=timeout, follow_redirects=False)
except TypeError:
if headers:
return httpx.get(url, headers=headers, timeout=timeout)
return httpx.get(url, timeout=timeout)
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
transport = httpx.HTTPTransport(trust_env=False)
transport._pool = pool
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
return client.get(url, headers=headers, timeout=timeout)
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
if httpx.post is not _ORIGINAL_HTTPX_POST:
try:
if headers:
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
except TypeError:
if headers:
return httpx.post(url, headers=headers, json=json, timeout=timeout)
return httpx.post(url, json=json, timeout=timeout)
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
transport = httpx.HTTPTransport(trust_env=False)
transport._pool = pool
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
return client.post(url, headers=headers, json=json, timeout=timeout)
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
def _probe_lmstudio_models(url: str) -> Optional[list]:
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
parsed = urlparse(url)
@@ -279,10 +127,10 @@ def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional
cached = _lmstudio_models_cache.get(key)
if cached is not None and cached[1] > now:
return cached[0]
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
authority = host if parsed.port is None else f"{host}:{parsed.port}"
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
try:
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
r = httpx.get(probe_url, timeout=1.0)
except Exception:
return None
try:
@@ -300,15 +148,15 @@ def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional
return models
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
if not model:
return None
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
if not _is_safe_local_host(urlparse(url).hostname):
# Never probe a remote provider; LM Studio is always a local/LAN host.
if not _is_local_host(urlparse(url).hostname):
return None
models = _probe_lmstudio_models(url, headers=headers)
models = _probe_lmstudio_models(url)
if not models:
return None
want = model.strip().lower()
@@ -324,79 +172,21 @@ def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = Non
return None
def model_supports_vision(
model_name: str,
endpoint_url: str = "",
owner: Optional[str] = None,
) -> bool:
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
"""Whether a model accepts images, using the endpoint's reported
capability when available (LM Studio), a cached one-time probe for local
OpenAI-compatible endpoints, and name-based detection otherwise."""
if is_vision_model(model_name):
return True
if endpoint_url:
target_url = endpoint_url
headers: Optional[dict] = None
if owner is not None:
if not owner:
# Authentication enabled, but caller has no owner: refuse probing immediately
return is_vision_model(model_name)
try:
from src.database import SessionLocal
from src.endpoint_resolver import (
normalize_base,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
with SessionLocal() as db:
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
resolved_base, api_key = resolve_endpoint_runtime(ep)
target_url = normalize_base(resolved_base or ep.base_url)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
except ValueError:
# Foreign-owned, disabled, or unregistered endpoint
return is_vision_model(model_name)
except Exception as e:
logger.warning("Endpoint authorization check failed: %s", e)
return is_vision_model(model_name)
else:
# Single-user / auth-disabled mode (owner is None):
try:
from src.database import SessionLocal
from src.endpoint_resolver import (
normalize_base,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
with SessionLocal() as db:
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
resolved_base, api_key = resolve_endpoint_runtime(ep)
target_url = normalize_base(resolved_base or ep.base_url)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
except Exception:
# In single-user mode, an unregistered endpoint is allowed
# only if target URL passes safe local host checks (probed with headers=None).
target_url = endpoint_url
headers = None
try:
if headers:
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
else:
advertised = lmstudio_supports_vision(target_url, model_name or "")
except TypeError:
advertised = lmstudio_supports_vision(target_url, model_name or "")
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
except Exception:
advertised = None
if advertised is not None:
return advertised
try:
if headers:
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
else:
probed = probe_endpoint_supports_vision(target_url, model_name or "")
except TypeError:
probed = probe_endpoint_supports_vision(target_url, model_name or "")
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
except Exception:
probed = None
if probed is not None:
@@ -425,11 +215,7 @@ def _chat_completions_url(endpoint_url: str) -> str:
return base + "/chat/completions"
def probe_endpoint_supports_vision(
endpoint_url: str,
model_name: str,
headers: Optional[dict] = None,
) -> Optional[bool]:
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
an image_url content part for `model_name`?
@@ -440,8 +226,8 @@ def probe_endpoint_supports_vision(
if not endpoint_url or not model_name or not _vision_probe_enabled():
return None
parsed = urlparse(endpoint_url)
if not _is_safe_local_host(parsed.hostname):
return None # never probe a public provider or disallowed host
if not _is_local_host(parsed.hostname):
return None # never probe a public provider
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
now = time.time()
cached = _vision_probe_cache.get(key)
@@ -461,12 +247,7 @@ def probe_endpoint_supports_vision(
}
verdict: Optional[bool] = None
try:
r = _safe_probe_post(
_chat_completions_url(endpoint_url),
headers=headers,
json=payload,
timeout=8.0,
)
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
if r.status_code == 200:
verdict = True
elif r.status_code in (400, 413, 415, 422):
+8 -39
View File
@@ -1466,29 +1466,6 @@ def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_us
return None
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
try:
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
if not spec_router.think_router_enabled():
return None
from src.agent_loop import _configured_model_tool_surface_raw
if _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers
) != 'spec':
return None
if hasattr(spec_router, 'route_thinking_async'):
return await spec_router.route_thinking_async(direct_user_text)
return spec_router.route_thinking(direct_user_text)
except Exception:
logging.getLogger(__name__).warning(
'spec think router decision failed; using rules only', exc_info=True)
return None
def spec_router_think_reason(round_number, decision, existing_deliberate):
"""Add 'router_think' on round 1 when the router asked for thinking and no
other rule already selected a deliberate reason."""
@@ -1531,7 +1508,10 @@ def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
decision = spec_router.latest_decision()
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
if not decisions:
return None
decision = decisions[-1]
if not isinstance(decision, dict):
return None
# Guard against a stale decision from a previous turn.
@@ -1593,7 +1573,8 @@ def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
set(disabled_tools or ()), list(conversation or []),
return_decision=True,
)
decision = spec_router.latest_decision() or {}
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
decision = decisions[-1] if decisions else {}
if not isinstance(decision, dict) or decision.get('error'):
return None
choice = decision.get('choice')
@@ -3881,9 +3862,6 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
or bare in BROKERED_JOB_TOOLS
or (contract_required and bare in {'edit_image', 'generate_image'})
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
# The editor command is conservatively classified as a write;
# document.info is a read even when the user requested no edit.
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
)
)
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
@@ -5622,10 +5600,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
external_tool_schemas=None, temperature=0.0,
context_resolution=None,
request_authority=MISSING_AUTHORITY, **ignored):
if spec_driver_active(endpoint_url, model, owner, headers):
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
yield 'data: [DONE]\n\n'
return
from src.generation_sampling import validate_temperature
temperature = validate_temperature(temperature)
# This path sends requests directly with httpx and therefore bypasses
@@ -6121,7 +6095,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
has_real_usage = intent_accounting.get('usage_source') == 'real'
# Optional env-gated router decision (spec surface only). Computed
# once per user turn; disabled/None leaves behaviour unchanged.
_spec_route_think = await spec_router_thinking_decision_async(
_spec_route_think = spec_router_thinking_decision(
endpoint_url, model, owner, headers, direct_user_text)
if _spec_route_think is not None:
logging.getLogger(__name__).info(
@@ -6134,17 +6108,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
# round 1 is sent with tool_choice 'none' (same tools list).
_spec_direct_p = spec_direct_no_tools_p(
direct_user_text, endpoint_url, model, owner, headers)
if (turn_contract.required or turn_contract.unavailable
or turn_contract.required_read_operation is not None):
_spec_direct_p = None
# Option B: send round 1 with no tools at all for a direct answer.
_spec_direct_empty_menu = (
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
# every round; the 9B only writes the arguments or the answer.
# Driver execution is refused at entry. Keep this false even if
# configuration changes while the stream is already in progress.
_spec_driver_on = False
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
# Driver mode narrows the contract per round to the Spec pick.
# Rebuild each round from this frozen base so the narrowing does
# not accumulate/mutate across rounds.
+3 -4
View File
@@ -92,7 +92,7 @@ def prune_multimodal_images(
return pruned
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
def _image_turn_label(message: Dict) -> str:
@@ -102,8 +102,7 @@ def _image_turn_label(message: Dict) -> str:
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
image_url = item.get("image_url")
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
if candidate:
name = str(candidate)
break
@@ -147,7 +146,7 @@ def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict
trimmed.append(message)
continue
kept_blocks = [
dict(item) if isinstance(item, dict) else item for item in content
item for item in content
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
]
if len(kept_blocks) == len(content):
+6 -240
View File
@@ -339,231 +339,14 @@ async def self_check(
return out
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
"""Resolve an explicit agent_model override under server authority.
Guarantees:
- Client-provided raw URLs never determine arbitrary outbound network destinations.
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
- Shared endpoints (owner IS NULL) remain visible to authorized users.
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
- Admin privileges do not allow arbitrary unconfigured destinations.
"""
raw = str(override or "").strip()
if not raw:
return None
if "::" in raw:
target, requested_model = raw.split("::", 1)
target = target.strip()
requested_model = requested_model.strip()
else:
target = ""
requested_model = raw
# 1. Explicit local selection or matching a configured local base
if target == "local":
bases = local_bases()
if not bases:
return None
return {
"base": bases[0],
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
for lb in local_bases():
if target and target.rstrip("/") == lb.rstrip("/"):
return {
"base": lb,
"model": requested_model or local_model(),
"headers": None,
"source": "override",
}
# 2. Explicit hosted fallback selection
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model:
if target in ("default", "hosted"):
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
if target and target.rstrip("/") == url.rstrip("/"):
if not requested_model or requested_model == model:
return {
"base": url,
"model": requested_model or model,
"headers": headers,
"source": "hosted",
}
except Exception:
pass
if target in ("default", "hosted"):
return None
# 3. Bare model id without endpoint prefix
if not target:
if requested_model == "local":
bases = local_bases()
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
if requested_model in ("default", "hosted"):
return _resolve_override_candidate("default::", owner)
# Exact match with local model
if requested_model == local_model():
bases = local_bases()
if bases:
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
# Exact match with hosted default/utility
try:
from src.endpoint_resolver import resolve_endpoint
for prefix in ("utility", "default"):
try:
url, model, headers = resolve_endpoint(prefix, owner=owner)
except Exception:
continue
if url and model and model == requested_model:
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
except Exception:
pass
# Check registered database endpoints for this bare model
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
matching_eps = []
for ep in q.all():
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model in enabled_models and requested_model not in hidden_models:
matching_eps.append(ep)
# Avoid model ID collision across providers: only accept if unique
if len(matching_eps) == 1:
ep = matching_eps[0]
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": requested_model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
pass
return None
# 4. Target is an endpoint_id or a registered legacy endpoint URL
try:
from core.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter, _auth_disabled
from src.endpoint_resolver import (
resolve_endpoint_runtime,
build_chat_url,
build_headers,
_endpoint_enabled_models,
_endpoint_hidden_models,
_first_chat_model,
)
db = SessionLocal()
try:
if not owner and not _auth_disabled():
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True,
ModelEndpoint.owner == None,
)
else:
q = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint,
owner,
)
# Lookup by ID
ep = q.filter(ModelEndpoint.id == target).first()
# If not found by ID, match legacy registered base URL (strictly registered rows only)
if ep is None and ("://" in target or target.startswith("http")):
target_norm = target.rstrip("/")
for cand_ep in q.all():
cand_base = (cand_ep.base_url or "").rstrip("/")
if (
cand_base == target_norm
or cand_base + "/v1" == target_norm
or cand_base == target_norm + "/v1"
):
ep = cand_ep
break
if ep is None:
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
return None
enabled_models = _endpoint_enabled_models(ep)
hidden_models = _endpoint_hidden_models(ep)
if requested_model:
if requested_model in hidden_models:
return None
if enabled_models and requested_model not in enabled_models:
# Model not served by this endpoint -> fail closed against forged model IDs
return None
model = requested_model
else:
model = _first_chat_model(enabled_models)
if not model:
return None
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
return {
"base": build_chat_url(base),
"model": model,
"headers": build_headers(api_key, base),
"source": "registered",
"endpoint_id": ep.id,
}
finally:
db.close()
except Exception:
return None
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
cands: List[Dict[str, Any]] = []
if override:
base, model = _split_override(override)
if model:
cands.append(
{"base": base or local_bases()[0], "model": model, "headers": None, "source": "override"}
)
for base in local_bases():
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
try:
@@ -579,14 +362,6 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
break
except Exception:
pass
if override:
cand = _resolve_override_candidate(override, owner)
if cand is not None:
cands.insert(0, cand)
return cands
# Unauthorized, invalid, foreign, disabled, or forged override:
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
return []
return cands
@@ -658,11 +433,9 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
"source": "local",
"vision": True,
"base_url": base,
"endpoint_id": "local",
})
try:
from src.endpoint_resolver import resolve_endpoint
from src.settings import get_user_setting, load_settings
for prefix in ("utility", "default"):
try:
@@ -673,19 +446,12 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
key = ("hosted", model)
if key not in seen:
seen.add(key)
ep_id = None
try:
settings = load_settings()
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
except Exception:
pass
models.append({
"id": model,
"label": model,
"source": "hosted",
"vision": None,
"base_url": url,
"endpoint_id": ep_id or prefix,
})
break
except Exception:
+7 -7
View File
@@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile(
)
_RECOLOR_TRAILING_ADJECTIVES = frozenset({
"bright", "brighter", "vivid", "vibrant", "dark", "darker", "light",
"lighter", "deep", "pale", "more", "less", "very", "really",
"slightly", "totally", "completely",
})
_RECOLOR_TRAILING_ADJ = re.compile(
r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|"
r"pale|more|less|very|really|slightly|totally|completely)\b\s*$",
re.I,
)
def _hex_rgb01(hex_color: str):
@@ -557,9 +557,9 @@ def _recolor_object_phrase(prompt: str, target_word: str) -> str:
words.pop(0)
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
words.pop()
while len(words) > 1 and words[-1].lower() in _RECOLOR_TRAILING_ADJECTIVES:
words.pop()
phrase = " ".join(words)
while phrase and _RECOLOR_TRAILING_ADJ.search(phrase):
phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip()
return phrase or "the subject"
+6
View File
@@ -119,6 +119,12 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
bare = tool.removeprefix("mcp__email__")
if bare == "list_email_accounts":
accounts = {}
for index, message in enumerate(messages):
row = _row(index, message)
identity = (row["account_id"], row["account"], row["account_email"])
accounts.setdefault(identity, {"id": row["account_id"], "name": row["account"],
"email": row["account_email"], "default": not accounts})
return {
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
"output": "Fixture mailbox (default)",
+11 -12
View File
@@ -17,7 +17,6 @@ Env:
from __future__ import annotations
import logging
import math
import os
from typing import Any, Dict, Optional
@@ -99,18 +98,18 @@ def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
if not isinstance(answer, dict):
return None
try:
if answer.get("type") == "score":
value = float(answer.get("score"))
elif answer.get("type") == "choice":
probs = answer.get("probabilities") or {}
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
else:
if answer.get("type") == "score":
try:
return float(answer.get("score"))
except Exception:
return None
except Exception:
return None
# A malformed adapter reply must fall back like any other failure.
return value if math.isfinite(value) else None
if answer.get("type") == "choice":
probs = answer.get("probabilities") or {}
try:
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
except Exception:
return None
return None
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
+6 -20
View File
@@ -432,15 +432,12 @@ class _DegenerateStreamGuard:
"Summer Summer ..."). This is not a useful response and can burn context,
browser memory, and GPU time. Keep the guard conservative: only fire on long
same-token runs or a very dominant repeated token in the recent window.
Repetition made only of numbers is data (a zero or identity matrix, a
padded buffer), so it only stops at a long all-numeric backstop.
"""
def __init__(self, model: str):
self.model = model or "model"
self.last_token = ""
self.same_run = 0
self.numeric_run = 0
self.recent_tokens: List[str] = []
self.periodic_tokens: List[str] = []
self.total_chars = 0
@@ -461,7 +458,6 @@ class _DegenerateStreamGuard:
else:
self.last_token = token
self.same_run = 1
self.numeric_run = self.numeric_run + 1 if token.isdigit() else 0
self.recent_tokens.append(token)
self.periodic_tokens.append(token)
if len(self.recent_tokens) > 96:
@@ -470,20 +466,14 @@ class _DegenerateStreamGuard:
self.periodic_tokens = self.periodic_tokens[-2048:]
reason = None
if self.same_run >= 28 and self.total_chars >= 100 and not self.last_token.isdigit():
if self.same_run >= 28 and self.total_chars >= 100:
reason = f"repeated '{self.last_token}' {self.same_run} times"
elif len(self.recent_tokens) >= 72:
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
count = self.recent_tokens.count(top)
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not top.isdigit():
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
if (
not reason
and len(self.recent_tokens) >= 48
# A window that is mostly numbers is matrix/table data; its
# periodicity is real. Mixed loops still hit the exact-block check.
and sum(t.isdigit() for t in self.recent_tokens) * 2 < len(self.recent_tokens)
):
if not reason and len(self.recent_tokens) >= 48:
# Detect a periodic suffix instead of counting repeated n-grams.
# Reused property names are normal in CSS/SVG/JSON; a collapsed
# generation repeats nearly every token at a short fixed period.
@@ -520,13 +510,10 @@ class _DegenerateStreamGuard:
period = current_anchor - previous
if period < 24 or len(history) < period * 2:
continue
if history[-2 * period:-period] == history[-period:] and not all(t.isdigit() for t in history[-period:]):
if history[-2 * period:-period] == history[-period:]:
reason = f"repeated an exact {period}-token block twice"
break
if not reason and self.numeric_run >= 2048:
reason = f"emitted {self.numeric_run} numbers in a row"
if not reason:
return None
@@ -1569,9 +1556,8 @@ def _build_chatgpt_responses_payload(
"stream": stream,
"store": False,
}
# ChatGPT plan Responses requests reject temperature for every model.
# The common caller still passes its preset value, but this provider must
# omit the field even for model names outside the known reasoning families.
if not _restricts_temperature(model):
payload["temperature"] = temperature
# ChatGPT Subscription Codex API does not support max_output_tokens —
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
# Do not include it in the payload.
+5 -15
View File
@@ -10,7 +10,6 @@ import logging
import os
import re
import asyncio
import contextlib
from typing import Any, Dict, List, Optional, Set, Tuple
from src.database import McpServer, SessionLocal
@@ -384,21 +383,12 @@ class McpManager:
provider = build_provider(server_id, url, on_redirect=_on_redirect)
stack = AsyncExitStack()
try:
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
tools_result = await session.list_tools()
except BaseException:
# Close the half-open transport here, in the task that entered
# it. Left for the garbage collector, its AnyIO cancel scope is
# exited from the wrong task and the event loop keeps spinning
# (#5518). Its own teardown error would only mask the real one.
with contextlib.suppress(Exception):
await stack.aclose()
raise
tools_result = await session.list_tools()
tools = []
for tool in tools_result.tools:
tools.append({
+1 -4
View File
@@ -193,10 +193,7 @@ class ModelDiscovery:
data = r.json()
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
ids = [
m["id"] for m in items
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
]
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
if ids:
return {
"host": host,
+2 -4
View File
@@ -102,10 +102,8 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
# - **label:** value <!-- field=NAME-ENC type=text -->
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
# Accept Unicode word characters for older saved markers, whose encoder left
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
_FIELD_BULLET_RE = re.compile(
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
)
@@ -117,7 +115,7 @@ def _encode_name(name: str) -> str:
"""
out = []
for ch in name or "":
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
if ch.isalnum() or ch in ("_", ".", "-"):
out.append(ch)
else:
for b in ch.encode("utf-8"):
+2
View File
@@ -78,6 +78,8 @@ def untrusted_context_message(
"""
safe_label = _sanitize_label(label)
text = "" if content is None else str(content)
import logging as _lg
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
text = _escape_guard_markers(text)
metadata: Dict[str, Any] = {
"trusted": False,
+51 -8
View File
@@ -12,6 +12,16 @@ from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__)
# Names that indicate a throwaway/test session
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
@@ -44,15 +54,14 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
"""Remove empty/Incognito sessions and optionally organize saved chats.
"""Run session cleanup + (optional) AI folder sort for the given owner.
Args:
owner: user whose sessions to process
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions);
skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
skip Phase 2 (AI folder assignment). Used by the built-in daily
background sweep so it never burns LLM tokens.
delete_throwaway: retained for caller compatibility. Ordinary sessions
with saved messages are always preserved, regardless of this flag.
delete_throwaway: when False, only empty/incognito sessions are deleted.
Returns a human-readable summary of what was done.
"""
@@ -62,7 +71,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
db = SessionLocal()
try:
# ── Phase 1: Delete only empty and explicitly Incognito sessions ──
# ── Phase 1: Delete empty/throwaway sessions ──
deleted_empty = 0
deleted_throwaway = 0
@@ -84,13 +93,47 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
if is_session_recently_active(row, now=cleanup_now):
continue
has_messages = db.query(DbMsg.id).filter(
msg_count = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id
).first() is not None
if not has_messages:
).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
should_delete = False
if msg_count == 0:
if is_fresh:
continue
should_delete = True
deleted_empty += 1
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
assistant_count = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id, DbMsg.role == "assistant"
).limit(1).count()
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
should_delete = True
deleted_throwaway += 1
else:
# Aggressive: total message text under 250 chars combined = trivial
msg_rows = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id
).all()
total_chars = sum(len(m[0] or "") for m in msg_rows)
if total_chars <= 250:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row)
if deleted_empty or deleted_throwaway:
+3 -2
View File
@@ -47,8 +47,9 @@ DEFAULT_SETTINGS = {
"image_gen_enabled": False,
"image_model": "",
"image_quality": "medium",
# Optional editor planner/caption endpoints. Empty uses configured chat
# fallback without probing a developer-specific host.
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
# back to the hosted chat model. See src/editor_agent_model.py.
"editor_agent_base_url": "",
"editor_agent_model": "",
"editor_caption_base_url": "",
+167 -5
View File
@@ -1,4 +1,13 @@
"""Helpers for the experimental Spec driver, whose execution is disabled."""
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
Each round the router (spec_router.next_action) picks one action from the full tool surface:
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
the executor runs it; the result goes back to the router for the next pick.
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
ask_user -> the chat model writes the question through the ask_user tool.
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
authority and tool policy (execute_tool_block); nothing else is layered on top.
"""
from __future__ import annotations
@@ -101,8 +110,161 @@ async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id
client_runtime_context=None, external_untrusted_context_seen=False,
max_tokens=768, max_rounds=8, temperature=0.0,
request_authority=MISSING_AUTHORITY, **ignored):
# This experimental runtime lacks the normal turn and completion gates.
# Refuse direct callers too, before contacting a router or executor.
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
from src.turn_contract import _ACTIVE_CONTRACT
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
started = time.monotonic()
surface = spec_surface(disabled_tools)
by_name = {_name(s): s for s in surface}
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
history = list(messages)
security = ToolRunSecurityContext(
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
security.observe_messages(messages)
picks, executions, done_calls = [], [], set()
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
recovery = {}
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
for round_number in range(1, max_rounds + 1):
rounds = round_number
action = await asyncio.to_thread(
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
if action is None:
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
if round_number == max_rounds and action['kind'] == 'tool':
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
picks.append({'round': round_number, **action})
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
'temperature': temperature, 'stream': True,
'stream_options': {'include_usage': True},
'chat_template_kwargs': {'enable_thinking': False}}
if action['kind'] == 'answer_directly':
answer_parts = []
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
async for line in response.aiter_lines():
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
continue
try:
chunk = json.loads(line[6:])
except ValueError:
continue
if chunk.get('error') or chunk.get('object') == 'error':
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
u = chunk.get('usage') or {}
usage_in += u.get('prompt_tokens') or 0
usage_out += u.get('completion_tokens') or 0
for choice in chunk.get('choices') or []:
text = (choice.get('delta') or {}).get('content') or ''
if text:
first_token = first_token or time.monotonic()
answer_parts.append(text)
yield event({'delta': text})
answer = ''.join(answer_parts).strip()
if not answer:
yield event({'delta': 'The model returned no answer.'})
history.append({'role': 'assistant', 'content': answer})
break
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
options = [name]
if action['kind'] == 'tool' and topk > 1:
options = [n for n, _ in action.get('top') or []][:topk] or [name]
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
for n in options]
options = [n for n, sc in zip(options, schemas) if sc is not None]
schemas = [sc for sc in schemas if sc is not None]
if not schemas:
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
latest = f'Tool {name} is not available.'
continue
request['tools'] = [grammar_safe(sc) for sc in schemas]
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
if len(options) == 1 else 'required')
if len(options) > 1:
logger.info('[spec-driver] round=%s options=%s', round_number, options)
try:
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
except (RuntimeError, httpx.HTTPError) as exc:
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
latest = f'The {name} call could not be produced: {exc}'
continue
first_token = first_token or time.monotonic()
usage_in += usage.get('prompt_tokens') or 0
usage_out += usage.get('completion_tokens') or 0
call = next((c for c in calls if c['name'] in options), None)
if call is not None and len(options) > 1:
name = call['name']
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
round_number, name, options.index(name) + 1, options)
picks[-1]['chosen'] = name
if call is None:
latest = f'The {name} call was not produced.'
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
continue
arguments = call['arguments'] or '{}'
signature = (name, arguments)
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
history.append({'role': 'assistant', 'content': content or '',
'tool_calls': [{'id': call_id, 'type': 'function',
'function': {'name': name, 'arguments': arguments}}]})
if signature in done_calls:
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
latest = output
continue
try:
args = json.loads(arguments)
except ValueError:
args = {}
block = function_call_to_tool_block(name, arguments)
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
'full_command': arguments, 'round': round_number})
tool_started = time.monotonic()
if block is None:
desc, result = f'{name}: invalid arguments', {
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
else:
desc, result = await execute_tool_block(
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
tool_policy=tool_policy, security_context=security,
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
client_runtime_context=client_runtime_context, request_authority=request_authority)
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
output = preview_tool_result_text(result, name, args)
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
'desc': desc, 'round': round_number,
'seconds': round(time.monotonic() - tool_started, 3)}
if result.get('image_url'):
yield event({'type': 'generated_image', 'url': result['image_url'],
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
if result.get('ui_event'):
yield event({'type': 'ui_control', 'data': result})
executions.append(tool_event)
yield event(tool_event)
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
if not failed:
done_calls.add(signature)
latest = output
ask = result.get('ask_user')
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
question = str(ask['question']).strip()
history.append({'role': 'assistant', 'content': question})
yield event({'type': 'final_response', 'content': question})
yield event({'type': 'ask_user', 'data': ask})
break
elapsed = time.monotonic() - started
yield event({'type': 'metrics', 'data': {
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
'time_to_first_token': round(first_token - started, 3) if first_token else None,
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
}})
yield 'data: [DONE]\n\n'
return
+9 -274
View File
@@ -1,9 +1,9 @@
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
The router (a 0.8B decision model served by jeff-serve) reads the user's
message and scores offered tools in one forward pass. The harness may narrow
the existing turn contract, but cannot add authority or drop its obligations.
On an error, timeout or low-confidence answer it retains the existing contract.
message and scores every offered tool in one forward pass. The harness only
ever ADDS tools from its answer, never removes any, and fails open: on any
error, timeout or low-confidence answer the compact tool set is unchanged.
Env:
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
@@ -17,17 +17,12 @@ from __future__ import annotations
import json
import logging
import math
import os
import time
import asyncio
import urllib.request
import urllib.error
from contextvars import ContextVar
from typing import Any, Dict, Iterable, List, Optional, Set
import httpx
logger = logging.getLogger(__name__)
_HEAD = {
@@ -40,16 +35,8 @@ _INSTRUCTIONS = (
"If a tool is needed but information it requires is missing, choose ask the user."
)
_AGENT = "Odysseus, a personal tool-using assistant."
_LAST_DECISION: ContextVar[Optional[Dict[str, Any]]] = ContextVar('spec_router_decision', default=None)
def latest_decision() -> Optional[Dict[str, Any]]:
"""Return the router decision in the current request context."""
return _LAST_DECISION.get()
def clear_decision() -> None:
_LAST_DECISION.set(None)
# Recent decisions, for debugging in the UI/logs: most recent last.
LAST_DECISIONS: List[Dict[str, Any]] = []
_MCP_BROWSER_TOOLS = (
"mcp__builtin_browser__browser_navigate",
"mcp__builtin_browser__browser_snapshot",
@@ -118,7 +105,6 @@ def spec_route_tools(
the router answered confidently (a tool pick, or answer_directly)."""
added: Set[str] = set(prior_tool_names(messages))
decided = False
clear_decision()
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
if not url or not (user_message or "").strip():
return (added, False) if return_decision else added
@@ -157,15 +143,6 @@ def spec_route_tools(
time.sleep(0.1)
probs = out.get("probabilities") or {}
choice = out.get("choice")
if not isinstance(probs, dict) or choice not in criteria:
raise ValueError("invalid router choice")
for key, probability in probs.items():
if key not in criteria or isinstance(probability, bool):
raise ValueError("invalid router probability key")
number = float(probability)
if not math.isfinite(number) or not 0 <= number <= 1:
raise ValueError("invalid router probability")
probs = {key: float(value) for key, value in probs.items()}
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
ranked = sorted(
@@ -195,133 +172,11 @@ def spec_route_tools(
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
except Exception as exc: # fail open
added = set(prior_tool_names(messages))
decided = False
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
logger.warning("spec router failed open: %s", exc)
LAST_DECISIONS.append(decision)
del LAST_DECISIONS[:-50]
decision["decided"] = decided
_LAST_DECISION.set(decision)
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
return (added, decided) if return_decision else added
_ORIGINAL_SPEC_ROUTE_TOOLS = spec_route_tools
async def spec_route_tools_async(
user_message: str,
schemas: Iterable[Dict[str, Any]],
disabled_tools: Optional[Set[str]] = None,
messages: Optional[Iterable[Dict[str, Any]]] = None,
return_decision: bool = False,
):
"""Asynchronous non-blocking tool routing.
Preserves request-local ContextVar semantics, decision propagation,
cancellation behavior, timeout limits, and safe fallback on router failure.
"""
if spec_route_tools is not _ORIGINAL_SPEC_ROUTE_TOOLS:
return spec_route_tools(
user_message,
schemas,
disabled_tools=disabled_tools,
messages=messages,
return_decision=return_decision,
)
added: Set[str] = set(prior_tool_names(messages))
decided = False
clear_decision()
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
if not url or not (user_message or "").strip():
return (added, False) if return_decision else added
t0 = time.time()
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
try:
disabled = set(disabled_tools or ())
lines: Dict[str, str] = {}
for s in schemas:
n = str(_fn(s).get("name") or "")
if n and n not in disabled and n != "ask_user" and n not in lines:
lines[n] = tool_line(s)
names = sorted(lines)
criteria = dict(_HEAD)
for i, n in enumerate(names, 1):
criteria[f"t{i}"] = lines[n]
req = {
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
}
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
deadline = time.time() + timeout
async with httpx.AsyncClient(timeout=timeout) as client:
while True:
remaining = max(0.2, deadline - time.time())
try:
resp = await client.post(
url + "/v1/systemone",
json=req,
headers={"content-type": "application/json"},
timeout=remaining,
)
if resp.status_code == 529:
if time.time() + 0.15 >= deadline:
resp.raise_for_status()
decision["retries"] = decision.get("retries", 0) + 1
await asyncio.sleep(0.1)
continue
resp.raise_for_status()
out = resp.json()["answers"]["tool"]
break
except httpx.HTTPStatusError as busy:
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
raise
decision["retries"] = decision.get("retries", 0) + 1
await asyncio.sleep(0.1)
probs = out.get("probabilities") or {}
choice = out.get("choice")
if not isinstance(probs, dict) or choice not in criteria:
raise ValueError("invalid router choice")
for key, probability in probs.items():
if key not in criteria or isinstance(probability, bool):
raise ValueError("invalid router probability key")
number = float(probability)
if not math.isfinite(number) or not 0 <= number <= 1:
raise ValueError("invalid router probability")
probs = {key: float(value) for key, value in probs.items()}
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
ranked = sorted(
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
)
picks: List[str] = []
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
picks.append(names[int(choice[1:]) - 1])
for p, k in ranked[:3]:
nm = names[int(k[1:]) - 1]
if nm not in picks and p >= second_min:
picks.append(nm)
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
if _bmode != "0" and "private_browser" in picks:
picks = [n for n in picks if n != "private_browser"]
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
for n in extra + ["web_search", "web_fetch"]:
if n not in picks:
picks.append(n)
added.update(picks)
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
except Exception as exc: # fail open
added = set(prior_tool_names(messages))
decided = False
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
logger.warning("spec router failed open: %s", exc)
decision["decided"] = decided
_LAST_DECISION.set(decision)
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
return (added, decided) if return_decision else added
@@ -473,59 +328,13 @@ def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
if kind5:
out["kind"] = ans.get("choice")
logger.info("spec think router: %s", out)
logger.info("spec think router: %s msg=%r", out, text[:120])
return out
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
return None
_ORIGINAL_ROUTE_THINKING = route_thinking
async def route_thinking_async(user_message: str) -> Optional[Dict[str, Any]]:
"""Asynchronous non-blocking route_thinking."""
if route_thinking is not _ORIGINAL_ROUTE_THINKING:
return route_thinking(user_message)
if not think_router_enabled():
return None
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
text = (user_message or "").strip()
if not url or not text:
return None
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
req = {
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
"questions": {"thinking": question},
}
t0 = time.time()
try:
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url + "/v1/systemone",
json=req,
headers={"content-type": "application/json"},
timeout=timeout,
)
resp.raise_for_status()
ans = resp.json()["answers"]["thinking"]
probs = ans.get("probabilities") or {}
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
if kind5:
out["kind"] = ans.get("choice")
logger.info("spec think router: %s", out)
return out
except Exception as exc:
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
return None
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
messages: Optional[Iterable[Dict[str, Any]]] = None,
@@ -580,81 +389,7 @@ def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[st
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
logger.info("spec next action: %s", action)
return action
except Exception as exc:
logger.warning("spec next action failed: %s", exc)
return None
_ORIGINAL_NEXT_ACTION = next_action
async def next_action_async(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
messages: Optional[Iterable[Dict[str, Any]]] = None,
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
"""Asynchronous non-blocking next_action."""
if next_action is not _ORIGINAL_NEXT_ACTION:
return next_action(user_message, latest_result, schemas, messages=messages, disabled_tools=disabled_tools)
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
if not url:
return None
t0 = time.time()
disabled = set(disabled_tools or ())
lines: Dict[str, str] = {}
for s in schemas:
n = str(_fn(s).get("name") or "")
if n and n not in disabled and n != "ask_user" and n not in lines:
lines[n] = tool_line(s)
names = sorted(lines)
criteria = dict(_HEAD)
for i, n in enumerate(names, 1):
criteria[f"t{i}"] = lines[n]
um = (user_message or "")[-3000:]
if latest_result:
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
req = {
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
}
try:
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
deadline = time.time() + timeout
async with httpx.AsyncClient(timeout=timeout) as client:
while True:
remaining = max(0.2, deadline - time.time())
try:
resp = await client.post(
url + "/v1/systemone",
json=req,
headers={"content-type": "application/json"},
timeout=remaining,
)
if resp.status_code == 529:
if time.time() + 0.15 >= deadline:
resp.raise_for_status()
await asyncio.sleep(0.1)
continue
resp.raise_for_status()
out = resp.json()["answers"]["tool"]
break
except httpx.HTTPStatusError as busy:
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
raise
await asyncio.sleep(0.1)
probs = out.get("probabilities") or {}
choice = str(out.get("choice") or "")
if choice.startswith("t") and choice[1:].isdigit():
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
elif choice in ("answer_directly", "ask_user"):
action = {"kind": choice, "name": choice}
else:
return None
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
logger.info("spec next action: %s", action)
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
return action
except Exception as exc:
logger.warning("spec next action failed: %s", exc)
+12 -10
View File
@@ -47,23 +47,25 @@ def resolve_task_candidates(
candidates.append((url, model, headers or {}))
if override_url and override_model:
headers = override_headers or {}
try:
from src.database import SessionLocal
from src.endpoint_resolver import (
build_chat_url, build_headers, resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
from src.database import ModelEndpoint, SessionLocal
from src.endpoint_resolver import normalize_base, resolve_endpoint_runtime, build_headers
db = SessionLocal()
try:
ep = resolve_owner_registered_endpoint(db, override_url, owner)
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
_append(build_chat_url(runtime_base), override_model, build_headers(api_key, runtime_base))
from src.auth_helpers import owner_filter
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
for ep in owner_filter(query, ModelEndpoint, owner).all():
base = normalize_base(getattr(ep, "base_url", "") or "")
if _same_endpoint_base(override_url, base):
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
headers = build_headers(api_key, runtime_base or base)
break
finally:
db.close()
except Exception:
# A missing, foreign, disabled or unreadable registration is not
# authority to contact a caller-provided endpoint or use its key.
pass
_append(override_url, override_model, headers)
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
_append(*resolve_endpoint("utility", owner=owner))
_append(*resolve_endpoint("default", owner=owner))
+27 -72
View File
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
from typing import Any, Awaitable, Callable, Dict, Tuple
from core.auth import RESERVED_USERNAMES
from src.owner_identity import REQUEST_SENTINEL_OWNERS, auth_disabled
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
@@ -21,15 +21,6 @@ from src.task_action_policy import (
logger = logging.getLogger(__name__)
class TaskEndpointAuthorityError(RuntimeError):
"""A scheduled task's endpoint or its credentials cannot be authorized."""
def _require_task_owner(owner):
if not str(owner or "").strip() and not auth_disabled():
raise TaskEndpointAuthorityError("Scheduled task has no owner in authenticated mode")
def _is_sft_fixture_owner(owner: str | None) -> bool:
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
return str(owner or "").strip().lower().startswith("sft_")
@@ -132,23 +123,6 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
_shared_cache_pending.pop(key, None)
def is_valid_cron(cron_expression: str) -> bool:
"""True for a standard 5-field cron expression that croniter can parse.
croniter also accepts 6- and 7-field expressions with a seconds field, but
the scheduler works at one-minute resolution and would run such a task
every second.
"""
if len((cron_expression or "").split()) != 5:
return False
try:
from croniter import croniter
croniter(cron_expression)
except Exception:
return False
return True
def compute_next_run(schedule: str, scheduled_time: str,
scheduled_day: int = None,
scheduled_date: datetime = None,
@@ -192,9 +166,6 @@ def compute_next_run(schedule: str, scheduled_time: str,
return dt.astimezone(timezone.utc).replace(tzinfo=None)
if schedule == "cron" and cron_expression:
if not is_valid_cron(cron_expression):
logger.warning(f"Invalid cron expression '{cron_expression}'")
return None
try:
from croniter import croniter
cron = croniter(cron_expression, now)
@@ -326,7 +297,7 @@ def _digest_windows(now):
def _checkin_calendar_events(db, owner, start, end):
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
The digest query had no owner scope, so it pulled EVERY user's events into
@@ -340,7 +311,7 @@ def _checkin_calendar_events(db, owner, start, end):
.filter(
_CC.owner == owner,
_CE.dtstart >= start,
_CE.dtstart < end,
_CE.dtstart <= end,
_CE.status != "cancelled",
)
.order_by(_CE.dtstart)
@@ -1374,7 +1345,6 @@ class TaskScheduler:
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
"""Execute a built-in action (no LLM needed)."""
_require_task_owner(task.owner)
from src.builtin_actions import BUILTIN_ACTIONS
from src.agent_runtime.authority import (
bind_request_authority, restore_task_authority, task_operation,
@@ -1664,7 +1634,6 @@ class TaskScheduler:
async def _execute_llm_task(self, task, db) -> str:
"""Execute an LLM task with full tool access via the agent loop."""
from core.database import Session as DbSession, ChatMessage, CrewMember
_require_task_owner(task.owner)
# If this task is wired to a CrewMember (personal assistant, custom
# crew), prefer the crew member's persona/model/endpoint as overrides.
@@ -1685,11 +1654,6 @@ class TaskScheduler:
endpoint_url, model = self._resolve_defaults(db, task.owner)
if not endpoint_url or not model:
raise RuntimeError("No model/endpoint configured")
# Authorize the original URL before normalization can erase a query,
# fragment, or caller-selected path. Stored tasks do not themselves
# grant permission to contact an endpoint.
from src.endpoint_resolver import resolve_owner_registered_endpoint
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
endpoint_url = _normalize_chat_endpoint(endpoint_url)
# Record the resolved model so _execute_task_locked can persist it on
# the run (tasks rarely pin a model, so this is the only record of
@@ -1808,8 +1772,6 @@ class TaskScheduler:
relevant_tools=relevant_tools,
datetime_context_msg=_dt_msg,
)
except TaskEndpointAuthorityError:
raise
except Exception as e:
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
from src.task_endpoint import task_llm_call_async
@@ -2024,7 +1986,6 @@ class TaskScheduler:
override_user_message: str | None = None,
datetime_context_msg: dict | None = None) -> str:
"""Run the full agent loop with tool access, collecting the final text."""
_require_task_owner(task.owner)
from src.agent_loop import stream_agent_loop
from src.agent_runtime.authority import restore_task_authority
@@ -2054,8 +2015,8 @@ class TaskScheduler:
headers = build_headers(api_key, base)
finally:
db2.close()
except Exception as exc:
raise TaskEndpointAuthorityError("Scheduled task endpoint is unavailable or unauthorized") from exc
except Exception:
pass
full_text = ""
final_text_replaced = False
tool_results = []
@@ -2211,16 +2172,11 @@ class TaskScheduler:
from src.settings import get_setting
# Resolve endpoint/model: research settings > task settings > session defaults
_require_task_owner(task.owner)
endpoint_url = task.endpoint_url
model = task.model
headers = {}
headers_from_resolver = False
if endpoint_url:
from src.endpoint_resolver import resolve_owner_registered_endpoint
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
if not endpoint_url or not model:
try:
from src.endpoint_resolver import resolve_endpoint
@@ -2257,8 +2213,8 @@ class TaskScheduler:
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
endpoint_url = build_chat_url(base)
headers = build_headers(api_key, base)
except Exception as exc:
raise TaskEndpointAuthorityError("Scheduled research endpoint is unavailable or unauthorized") from exc
except Exception:
pass
endpoint_url = _normalize_chat_endpoint(endpoint_url)
max_tokens, extraction_timeout, extraction_concurrency = (
@@ -2392,11 +2348,11 @@ class TaskScheduler:
have to special-case each tool's schema; the MCP tool ignores keys it
doesn't recognise.
"""
_require_task_owner(task.owner)
from src.tool_utils import get_mcp_manager
mcp = get_mcp_manager()
if not mcp:
raise RuntimeError("MCP manager not available for task delivery")
logger.warning(f"Task {task.id}: MCP manager not available for delivery")
return
# Resolve recipient — prefer the configured email From (the established
# "email yourself" pattern from daily_brief), fall back to task.owner.
@@ -2405,7 +2361,7 @@ class TaskScheduler:
recipient = None
try:
from routes.email_helpers import _get_email_config
cfg = _get_email_config(owner=task.owner or "") or {}
cfg = _get_email_config() or {}
recipient = cfg.get("from_address") or None
except Exception as _e:
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
@@ -2421,14 +2377,6 @@ class TaskScheduler:
"X-Odysseus-Ref": str(task.id),
},
}
if tool_name.startswith("mcp__email__"):
# The email server uses this reserved field for its account and
# approval scope; it must come from the task owner, never a model.
from src.tool_execution import _EMAIL_MCP_OWNER_ARG, _EMAIL_MCP_SESSION_ARG
if task.owner:
args[_EMAIL_MCP_OWNER_ARG] = task.owner
if getattr(task, "session_id", None):
args[_EMAIL_MCP_SESSION_ARG] = task.session_id
if recipient:
# Cover the common field names so we work across MCP servers (Gmail,
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
@@ -2443,18 +2391,25 @@ class TaskScheduler:
)
try:
mcp_result = await mcp.call_tool(tool_name, args)
if isinstance(mcp_result, dict):
from src.tool_execution import _normalize_mcp_text_error
mcp_result = _normalize_mcp_text_error(dict(mcp_result))
if not isinstance(mcp_result, dict) or mcp_result.get("error") or mcp_result.get("exit_code") != 0:
raise RuntimeError("MCP task delivery failed or returned an invalid result")
if tool_name == "mcp__email__send_email" and not str(mcp_result.get("stdout") or "").strip().startswith("Sent email to "):
raise RuntimeError("MCP email delivery did not report a completed send")
logger.info("Task %s delivered via MCP tool %s (recipient_set=%s, body=%sb)",
task.id, tool_name, bool(recipient), len(result or ""))
stderr = mcp_result.get("stderr", "")
stdout = mcp_result.get("stdout", "")
body_len = len(result or "")
exit_code = mcp_result.get("exit_code", 0)
if exit_code != 0:
logger.warning(
f"Task {task.id} MCP delivery FAILED via {tool_name}: "
f"exit={exit_code} stderr={stderr[:400]!r} stdout={stdout[:400]!r}"
)
else:
# Include the MCP tool's own stdout (e.g. email_server returns
# "Sent email to ... with subject ...") + the body size so a
# silent SMTP failure is easier to spot in the logs.
logger.info(
f"Task {task.id} delivered via MCP tool {tool_name} "
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
)
except Exception as e:
logger.error(f"Task {task.id} MCP delivery failed: {e}")
raise
async def run_task_now(self, task_id: str, *, force: bool = False):
"""Manually trigger a task execution."""
+2 -10
View File
@@ -227,16 +227,8 @@ _register(
ToolEffect.READ_PRIVATE,
)
_register(
{"plan_edit"},
ToolEffect.NETWORK_EGRESS,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{"run_editor_command"},
ToolEffect.READ_PRIVATE,
ToolEffect.WRITE_PRIVATE,
{"plan_edit", "run_editor_command"},
ToolEffect.UI_SIDE_EFFECT,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
)
_register(
{
@@ -410,7 +402,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
"manage_settings": frozenset(
{"set", "delete", "reset", "disable_tool"}
{"set", "delete", "reset", "disable_tool", "enable_tool"}
),
"manage_tokens": frozenset({"create", "delete"}),
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
+6 -8
View File
@@ -1559,7 +1559,7 @@ def _agent_subprocess_env() -> dict:
def _legacy_private_browser_enabled() -> bool:
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
@@ -1757,13 +1757,11 @@ async def execute_tool_block(
transport = operation.transport_tool
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
# The legacy process cannot establish the sealed browser/page identity
# required by the current runtime. Never execute it ahead of resource
# binding and the untrusted-context approval gate, even when opted in.
return f"{transport}: UNSUPPORTED", {
**page_unavailable(),
"error": "Legacy browser transport cannot establish the required browser authority.",
}
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
# producer in src.browser_identity refuses every page action in this
# build. This compatibility transport requires an explicit opt-in.
return await _legacy_private_browser(
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
if operation.tool == "private_browser":
try:
_, browser_args = parse_browser_operation(operation.input)
+1 -1
View File
@@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
"manage_tokens": "API token management: list, create, or delete API access tokens.",
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also turns tools off globally (disable_tool) and lists disabled ones (list_tools); re-enabling is only done by the user in Settings > Agent Tools. Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
"create_session": "Create a new chat with a name and model.",
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
"send_to_session": "Send a message to another chat. Cross-chat communication.",
+7 -8
View File
@@ -41,7 +41,6 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
"write_file": ("path",),
"edit_file": ("path",),
"apply_patch": ("patch_text", "patchText", "patch"),
"generate_image": ("prompt",),
"host_shell": ("command",),
}
@@ -1293,14 +1292,14 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function",
"function": {
"name": "manage_settings",
"description": "Manage user preferences and settings. Use `disable_tool`/`list_tools` to turn individual tools off globally or list what is disabled (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Disabled tools can only be re-enabled by the user in Settings > Agent Tools, never from chat. Use list/get/set/delete for free-form preferences.",
"description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "list_tools"]},
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]},
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
"tool": {"type": "string", "description": "Tool name to disable (for disable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
"tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
},
"required": ["action"]
}
@@ -1546,7 +1545,7 @@ FUNCTION_TOOL_SCHEMAS = [
"properties": {
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
"params": {"type": "object", "description": "Parameters for the op"},
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"},
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
},
"required": ["op"]
@@ -1561,7 +1560,7 @@ FUNCTION_TOOL_SCHEMAS = [
"parameters": {
"type": "object",
"properties": {
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
}
}
}
@@ -1584,7 +1583,7 @@ FUNCTION_TOOL_SCHEMAS = [
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
"session": {"type": "string", "description": "Editor session id"}
},
"required": ["op"]
}
@@ -2001,7 +2000,7 @@ FUNCTION_TOOL_SCHEMAS = [
},
]
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}:
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
-6
View File
@@ -769,8 +769,6 @@ _APP_API_BLOCKLIST_PREFIXES = (
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
# Use dedicated tools or UI flows instead.
_APP_API_BLOCKLIST_METHOD_PATH = (
("POST", "/api/tools"), # global tool policy is changed by a human in Settings
("PATCH", "/api/mcp/servers"), # per-server MCP tool policy and server toggles, same reason
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
("DELETE", "/api/cookbook/state"),
@@ -895,10 +893,6 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
return {"error": f"Unsupported method: {method}", "exit_code": 1}
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
if path.startswith("/api/tools"):
return {"error": "Tool policy cannot be replaced from chat. Re-enable tools in Settings > Agent Tools; use manage_settings to list or disable them.", "exit_code": 1}
if path.startswith("/api/mcp/servers"):
return {"error": "MCP server and tool policy cannot be changed from chat. Ask the user to change it in Settings > Integrations.", "exit_code": 1}
if "/api/email/accounts" in path:
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
if "/api/cookbook/packages/install" in path:
+8 -36
View File
@@ -315,7 +315,7 @@ body.bg-pattern-ascii-fireflies {
width: 240px;
background: var(--sidebar-bg, var(--panel));
border-right: 1px solid var(--border);
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, visibility 0.25s;
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -361,23 +361,12 @@ body.bg-pattern-ascii-fireflies {
/* !important so it beats the inline width init.js restores from storage —
otherwise the width never changes and only opacity animates, making the
collapse look instant. With this, width animates from the inline value
down to 0 via the .sidebar width transition.
display overrides the global .hidden utility, which would otherwise
remove the box before any transition can run. visibility (transitioned
on .sidebar) flips to hidden only once the collapse has finished,
taking the closed sidebar out of the tab order. */
display: flex;
down to 0 via the .sidebar width transition. */
width: 0 !important;
padding: 0 !important;
border: none;
overflow: hidden;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
/* IDs so this also beats the mobile drawer's !important transition. */
@media (prefers-reduced-motion: reduce) {
#sidebar, #icon-rail { transition: none !important; }
}
/* ===== Sidebar User Bar ===== */
.sidebar-user-bar {
@@ -460,6 +449,10 @@ body.bg-pattern-ascii-fireflies {
}
.sidebar.hidden .sidebar-user-bar {
display: none;
}
/* Sticky sidebar header — logo, never scrolls */
.sidebar-header {
display: flex;
@@ -680,24 +673,6 @@ body.bg-pattern-ascii-fireflies {
margin-bottom: calc(-1 * var(--safe-bottom));
padding-bottom: calc(8px + var(--safe-bottom));
}
/* Tuck the rail under the sidebar (which paints above it) and slide it
out as the sidebar collapses, so the combined width eases instead of
jumping by the rail's 48px when the two swap. */
.icon-rail,
.icon-rail.right-side {
margin-inline-end: -48px;
transition: margin-inline-end 0.25s ease, display 0.25s allow-discrete;
}
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
html.ody-sidebar-mini .icon-rail {
margin-inline-end: 0;
}
@starting-style {
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
html.ody-sidebar-mini .icon-rail {
margin-inline-end: -48px;
}
}
}
.icon-rail-divider {
width: 24px;
@@ -4592,9 +4567,7 @@ body.bg-pattern-ascii-fireflies {
width:100%; background:var(--bg); color:var(--fg);
border:1px solid var(--border); border-radius:6px;
padding:8px; font-family:inherit; font-size:0.95rem;
resize:both; outline:none;
field-sizing:content;
max-width:100%; box-sizing:border-box;
resize:vertical; outline:none;
min-height:80px;
}
.edit-textarea:focus { border-color:var(--red); }
@@ -5143,7 +5116,7 @@ body.bg-pattern-ascii-fireflies {
width: 80% !important;
max-width: 340px;
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s !important;
transition: transform 0.25s ease, opacity 0.25s ease !important;
opacity: 1 !important;
overflow: visible !important;
/* Force opaque background — Firefox mobile renders the
@@ -5174,7 +5147,6 @@ body.bg-pattern-ascii-fireflies {
}
html.ody-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar {
width: 80% !important;
transform: translateX(-100%) !important;
pointer-events: none !important;
opacity: 0 !important;
+6
View File
@@ -90,6 +90,12 @@
.lib-tabs,
.gallery-tabs,
.preset-tabs {
/* A flex-item tab strip defaults to min-width:auto, so it grows to its
content width inside a narrow modal and the end tabs get clipped by the
modal's overflow instead of scrolling. min-width:0 lets the strip shrink
to the container so the overflow-x:auto below actually engages. */
min-width: 0;
max-width: 100%;
flex-wrap: nowrap !important;
overflow-x: auto !important;
overflow-y: hidden;
+17 -106
View File
@@ -111,9 +111,7 @@
flex: 0 0 auto;
flex-wrap: nowrap;
row-gap: 4px;
height: 44px;
min-height: 44px;
max-height: 44px;
min-height: 40px;
padding: 4px 10px;
box-sizing: border-box;
border-bottom: 1px solid var(--border);
@@ -208,19 +206,9 @@
pushed to the right edge. */
flex: 1 1 auto;
}
/* When transform section is active in controls, suppress any underlying tool section
so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not
overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */
.ge-controls.ge-transform-active > div:not(#ge-transform-section),
.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section),
.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) {
display: none !important;
}
/* The Text tool's option set is too wide for the thin strip: let the bar grow
and wrap onto extra rows instead of clipping the trailing fields. */
.ge-options-bar.ge-options-tall {
height: auto;
max-height: none;
flex-wrap: wrap;
align-items: center;
overflow-y: visible;
@@ -266,7 +254,7 @@
right after the caret instead of leaving a wide gap. */
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
.ge-controls--in-options .ge-control-row {
margin: 0 !important;
margin: 0;
flex: 0 0 auto;
display: flex;
flex-direction: row;
@@ -500,12 +488,8 @@
}
.ge-opt-empty { font-size: 11px; opacity: 0.5; }
.ge-options-empty .ge-options-sep { display: none; }
/* An editor opened at phone width keeps its tool controls in their own bottom
sheet, so the strip has nothing to show. One opened wider than 700px moves
the controls into this strip; hiding it then would strand every tool control
(tablet widths of 701-820px, or a window narrowed after opening). */
@media (max-width: 820px) {
.ge-options-bar:not(:has(.ge-controls--in-options)) { display: none; }
.ge-options-bar { display: none; }
}
/* Document tab strip (Photoshop-style) — names the open document and
@@ -4214,11 +4198,7 @@ body.ge-editor-open textarea.ge-direct-text-editor {
flex-direction: column;
gap: 2px;
}
.ge-save-state-icon { display: inline-block; vertical-align: middle; }
.ge-save-state-icon[hidden] { display: none !important; }
.ge-save-state-saved { color: var(--fg); }
.ge-save-state-dirty { color: var(--accent, var(--red)); }
.ge-save-state-error { color: var(--danger, #ef4444); }
.ge-save-state-icon { display: none; }
.ge-save-label { white-space: nowrap; min-width: 0; }
/* Save button: glyph on its own row, then the SAVE action label and the
live draft-status chip share the second row. */
@@ -6361,10 +6341,6 @@ button .spinner-whirlpool {
box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
display: none;
box-sizing: border-box;
contain: strict;
will-change: transform;
top: 0;
left: 0;
}
/* Pixel brush: a hard square footprint instead of a feathered circle. */
.ge-brush-cursor.ge-brush-cursor--pixel {
@@ -6646,7 +6622,6 @@ button .spinner-whirlpool {
}
.ge-right-panel.expanded {
transform: translateY(0);
z-index: 100;
}
/* Minimized: panel slides almost fully off-screen — only a thin
handle strip remains visible so the user can swipe back up. */
@@ -8159,12 +8134,7 @@ input.ge-ctl-check:focus-visible {
}
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
@media (max-width: 700px) {
/* Cover the phone viewport rather than the small, scrollable canvas area;
the layers sheet otherwise hides the checklist's lower controls. */
.ge-preflight { position: fixed; }
.ge-preflight-card { padding: 13px 14px; }
.ge-preflight-foot { flex-wrap: wrap; }
.ge-preflight-start { white-space: nowrap; }
}
/* ── Quick Edit — premium look ────────────────────────────────────────── */
@@ -8326,37 +8296,17 @@ input.ge-ctl-check:focus-visible {
gap: 6px;
padding: 3px 6px;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
white-space: nowrap;
min-width: 0;
}
.ge-editor-footer::-webkit-scrollbar {
display: none;
}
.ge-editor-footer .ge-status-tool,
.ge-editor-footer .ge-status-model,
.ge-editor-footer .ge-canvas-size {
display: none;
}
.ge-footer-history {
margin-right: 2px;
flex-shrink: 0;
}
.ge-footer-zoom {
gap: 3px;
min-width: 0;
flex-shrink: 0;
margin-left: 0;
}
.ge-footer-history { margin-right: 2px; }
.ge-footer-zoom { gap: 3px; min-width: 0; }
.ge-footer-zoom > .ge-btn,
.ge-footer-history > .ge-btn { min-width: 26px; }
.ge-setup-btn {
flex-shrink: 0;
}
/* Float the footer above the peek/expanded layers sheet so its zoom/setup
controls stay tappable (position/left/right/bottom set in JS). */
.ge-editor-footer.ge-footer-floating {
@@ -8367,42 +8317,13 @@ input.ge-ctl-check:focus-visible {
}
}
/* Mobile layers header layout */
/* Phones: the floating zoom/Setup footer collides with the peek/expanded
layers sheet and the Quick Edit card, so remove it entirely on phones.
Undo/Redo/History already live in the layers-sheet header, and pinch-zoom
replaces the zoom buttons. */
@media (max-width: 700px) {
.ge-layers-header {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
flex-wrap: nowrap;
white-space: nowrap;
gap: 4px;
padding-right: 8px;
}
.ge-layers-header::-webkit-scrollbar {
display: none;
}
.ge-layers-header .ge-footer-zoom {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
}
.ge-layers-header .ge-footer-zoom > .ge-btn,
.ge-layers-header .ge-footer-history > .ge-btn {
min-width: 28px;
min-height: 28px;
}
.ge-layers-header .ge-setup-btn {
flex-shrink: 0;
min-height: 28px;
padding: 3px 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 85%, var(--fg) 15%);
color: var(--fg);
cursor: pointer;
.ge-editor-footer {
display: none !important;
}
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
every other editor asset (sheet headers, tool rail, popups) while lifted. */
@@ -8635,13 +8556,6 @@ body.ge-editor-open .toast.toast-transform-hint {
position: relative;
align-items: stretch;
}
.ge-toolbar-wrap.ge-flyout-open {
z-index: 250;
}
.ge-toolbar.ge-flyout-open .ge-toolbar-scroll {
-webkit-mask-image: none !important;
mask-image: none !important;
}
.ge-toolbar-wrap > .ge-toolbar { flex: 1 1 auto; min-width: 0; }
.ge-tool-more {
display: none;
@@ -8665,7 +8579,6 @@ body.ge-editor-open .toast.toast-transform-hint {
/* Keep the floating editor footer from stacking on the Quick Edit card. The
card owns the bottom of the canvas while it is open, so the footer (zoom /
Setup / AI settings) hides until it closes. */
body.ge-qe-open .ge-editor-footer.ge-footer-floating,
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
display: none;
}
@@ -8744,14 +8657,16 @@ body.ge-editor-open .toast.toast-transform-hint {
}
}
/* Phone menu bar: tighten the Photoshop menus. The compact topbar already
drops the Save action label; the draft status stays visible because it is
the save button's only announced (role="status") save state. */
/* Phone menu bar: tighten the Photoshop menus and keep the Save control
icon-only so the twelve menus + Import + Save are not crowded. */
@media (max-width: 700px) {
.ge-topbar-left .ge-stacked-btn {
font-size: 11px;
padding: 0 6px !important;
}
.ge-topbar.ge-topbar-compact #ge-draft-status {
display: none;
}
}
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
@@ -9760,15 +9675,12 @@ body.ge-editor-open .tour-hint {
}
/* The dropdowns clip overflow to draw the custom scroll thumb; release the
clip while a flyout is open/hovered so it can extend past the menu edge. */
.ge-filter-menu.ge-flyout-open,
.ge-filter-menu:has(.ge-menu-parent.open),
.ge-filter-menu:has(.ge-menu-parent:hover),
.ge-filter-menu:has(.ge-submenu:hover),
.ge-image-menu.ge-flyout-open,
.ge-image-menu:has(.ge-menu-parent.open),
.ge-image-menu:has(.ge-menu-parent:hover),
.ge-image-menu:has(.ge-submenu:hover),
.ge-selection-menu.ge-flyout-open,
.ge-selection-menu:has(.ge-menu-parent.open),
.ge-selection-menu:has(.ge-menu-parent:hover),
.ge-selection-menu:has(.ge-submenu:hover) {
@@ -9777,7 +9689,6 @@ body.ge-editor-open .tour-hint {
mask-image: none;
}
/* The Window menu's Workspace flyout needs the same clip release. */
.ge-window-menu.ge-flyout-open,
.ge-window-menu:has(.ge-menu-parent.open),
.ge-window-menu:has(.ge-menu-parent:hover),
.ge-window-menu:has(.ge-submenu:hover) {
+3 -6
View File
@@ -2690,14 +2690,11 @@ body.theme-frosted .modal {
max-height: 95dvh !important;
}
/* Notes and Documents must respect the same viewport and safe-area
top as other mobile full-height sheets. Keep these authoritative rules
consistent with 05-documents.css and 14-email.css. */
body.doc-view .doc-editor-pane,
body.notes-view .notes-pane {
inset: var(--sheet-top) 0 0 !important;
height: calc(var(--app-height) - var(--sheet-top)) !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
inset: auto 0 0 !important;
height: 95dvh !important;
max-height: 95dvh !important;
}
}
+8 -1
View File
@@ -16,6 +16,13 @@
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
(function(){
var ua = navigator.userAgent || '';
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
var vp = document.querySelector('meta[name="viewport"]');
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
})();
(function(){
try {
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
@@ -250,7 +257,7 @@
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261017titlefade1">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
+1 -7
View File
@@ -771,13 +771,7 @@ function _getModal() {
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">&rsaquo;</button>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#cal-close').addEventListener('click', () => {
if (_modal.querySelector('.cal-form')) {
_render();
return;
}
closeCalendar();
});
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
+26 -243
View File
@@ -44,187 +44,34 @@ import { invalidateSettings } from './appConfig.js';
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
// Inline chat images share the editor's 8192px dimension ceiling, with a
// conservative 16MP budget for its multiple canvas surfaces. No blob URLs are
// needed here: chat previews use server-authorized upload references.
const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const _MAX_INLINE_BYTES = 7 * 1024 * 1024;
const _MAX_IMAGE_DIMENSION = 8192;
const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode.
function _imageDimensionsAllowed(width, height) {
return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION &&
height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS;
}
function _rasterDimensions(bytes, mime) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0));
if (mime === 'image/png' && bytes.length >= 33 &&
bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) {
return [view.getUint32(16), view.getUint32(20)];
}
if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) {
return [view.getUint16(6, true), view.getUint16(8, true)];
}
if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) {
let pos = 2;
while (pos + 4 <= bytes.length) {
if (bytes[pos++] !== 255) return null;
while (bytes[pos] === 255) pos++;
const marker = bytes[pos++];
if (marker === 218 || marker === 217) break;
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
if (pos + 2 > bytes.length) return null;
const size = view.getUint16(pos);
if (size < 2 || pos + size > bytes.length) return null;
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) {
return [view.getUint16(pos + 5), view.getUint16(pos + 3)];
}
pos += size;
}
}
if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') &&
view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) {
if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)];
if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)];
if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383];
}
return null;
}
function _inlineRaster(url) {
if (_inlineRasterCache?.url === url) return _inlineRasterCache;
const result = { url, valid: false };
_inlineRasterCache = result;
if (url.length > _MAX_DATA_URL_LENGTH) return result;
const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url);
if (!match) return result;
const payload = url.slice(match[0].length);
if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result;
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
const size = payload.length / 4 * 3 - padding;
if (size > _MAX_INLINE_BYTES) return result;
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result;
// URL of the photo attached to the current turn (chat uploads are served at
// /api/upload/<id>). Used by the "Open in editor & apply" action.
function _currentAttachedImageUrl() {
try {
const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => {
const binary = atob(payload); const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return array;
})();
const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg');
const dimensions = _rasterDimensions(bytes, mime);
if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result;
Object.assign(result, { valid: true, bytes, mime, dimensions });
} catch (_) {}
return result;
}
// This synchronous guard rejects untrusted references before any fetch/decode.
// Inline images additionally require a successful browser decode before launch.
function _isAuthorizedImageUrl(url) {
if (!url || typeof url !== 'string') return false;
const trimmed = url.trim();
if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid;
if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false;
try {
const base = window.location.origin;
const parsed = new URL(trimmed, base);
return parsed.origin === base && !parsed.search && !parsed.hash &&
/^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname);
} catch (_) { return false; }
}
async function _validateInlineImage(url) {
const raster = _inlineRaster(url.trim());
if (!raster.valid) return false;
if (!raster.decoded) raster.decoded = (async () => {
try {
const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime }));
try {
return _imageDimensionsAllowed(image.width, image.height) &&
image.width * image.height === raster.dimensions[0] * raster.dimensions[1];
} finally { image.close(); }
} catch (_) { return false; }
finally { raster.bytes = null; }
})();
return raster.decoded;
}
// Find the specific user message that originated the turn containing container.
function _findOriginatingUserMessage(container) {
if (!container) return null;
let curr = container.previousElementSibling;
while (curr) {
if (curr.classList && curr.classList.contains('msg-user')) {
return curr;
const box = document.getElementById('chat-history');
if (!box) return null;
// Use the newest user bubble that carries an image preview. Note
// ':last-of-type' is wrong here (it selects the last sibling of the
// element type, and the assistant reply follows the user bubble), so
// walk the user bubbles from the end instead.
const users = box.querySelectorAll('.msg-user');
for (let i = users.length - 1; i >= 0; i--) {
const img = users[i].querySelector('.attach-image-preview[data-file-id] img');
if (img && img.getAttribute('src')) return img.getAttribute('src');
}
curr = curr.previousElementSibling;
}
const box = document.getElementById('chat-history');
if (box && box.contains(container)) {
const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder'));
const idx = all.findIndex((el) => el === container || el.contains(container));
if (idx > 0) {
for (let i = idx - 1; i >= 0; i--) {
if (all[i].classList && all[i].classList.contains('msg-user')) {
return all[i];
}
}
// Fall back to any attachment preview/card in the history (newest).
const previews = box.querySelectorAll(
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
);
if (previews.length) {
const last = previews[previews.length - 1];
const id = last.dataset ? last.dataset.fileId : null;
if (id) return API_BASE + '/api/upload/' + id;
}
return null;
} catch (_) {
return null;
}
return null;
}
// Resolve the image attached to the exact turn associated with container / action.
// Never falls back to unrelated images from earlier conversation turns.
function _attachedImageForTurn(container, action) {
const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id);
if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) {
return (API_BASE || '') + '/api/upload/' + explicitId;
}
const explicitUrl = action && (action.imageUrl || action.image_url);
if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null;
const userMsg = _findOriginatingUserMessage(container);
if (!userMsg) return null;
const previews = userMsg.querySelectorAll(
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
);
for (let i = previews.length - 1; i >= 0; i--) {
const fid = previews[i].dataset ? previews[i].dataset.fileId : null;
if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) {
return (API_BASE || '') + '/api/upload/' + fid;
}
const img = previews[i].querySelector('img');
const src = img ? (img.getAttribute('src') || '') : '';
if (_isAuthorizedImageUrl(src)) return src;
}
const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img');
for (let i = imgs.length - 1; i >= 0; i--) {
const src = imgs[i].getAttribute('src') || '';
if (_isAuthorizedImageUrl(src)) return src;
}
return null;
}
function _currentAttachedImageUrl(container, action) {
return _attachedImageForTurn(container, action);
}
function _notifyError(msg) {
try {
if (uiModule && typeof uiModule.showError === 'function') {
uiModule.showError(msg);
return;
}
} catch (_) {}
console.warn('[chat]', msg);
}
// Render the one-click "Open in editor & apply" button on a photo-edit reply
@@ -238,89 +85,25 @@ import { invalidateSettings } from './appConfig.js';
btn.textContent = 'Open in editor & apply';
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
try {
let imageUrl = _attachedImageForTurn(container, action);
if (!imageUrl) {
_notifyError('No attached photo found for this conversation turn.');
btn.disabled = false;
return;
}
if (!_isAuthorizedImageUrl(imageUrl)) {
_notifyError('Unauthorized or invalid image reference.');
btn.disabled = false;
return;
}
if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) {
_notifyError('Invalid or oversized image attachment.');
return;
}
// Gallery references name an owner-scoped metadata resource, not image bytes.
if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) {
const response = await fetch(imageUrl, { credentials: 'same-origin' });
if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; }
const image = await response.json();
const source = new URL(image.url || '', window.location.origin);
if (source.origin !== window.location.origin || source.search || source.hash ||
!/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) {
_notifyError('Invalid gallery image reference.'); return;
}
imageUrl = source.href;
}
// Verify attachment is accessible and authorized on the server before launching editor
if (imageUrl.includes('/api/upload/')) {
try {
const probe = await fetch(imageUrl, { method: 'HEAD' });
if (!probe.ok) {
if (probe.status === 404 || probe.status === 410) {
_notifyError('The attached image is no longer available or upload has expired.');
} else if (probe.status === 401 || probe.status === 403) {
_notifyError('You are not authorized to edit this attached image.');
} else {
_notifyError('Could not verify image attachment authorization.');
}
btn.disabled = false;
return;
}
} catch (_) {
_notifyError('Could not verify image attachment authorization.');
btn.disabled = false;
return;
}
}
if (typeof window.gePhotoEditorApply !== 'function') {
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
}
await window.gePhotoEditorApply({
imageId: (action && (action.imageId || action.image_id)) || null,
imageUrl,
imageId: null,
imageUrl: _currentAttachedImageUrl(),
prompt: action.prompt || '',
displayName: 'Chat edit',
});
} catch (err) {
console.error('[chat] open_and_apply failed', err);
_notifyError('Failed to apply edit: ' + (err.message || err));
} finally {
btn.disabled = false;
}
});
container.appendChild(btn);
}
if (typeof window !== 'undefined') {
window.__chatPhotoEditorTestHooks = {
_isAuthorizedImageUrl,
_findOriginatingUserMessage,
_attachedImageForTurn,
renderPhotoEditorApplyButton,
};
}
let API_BASE = '';
let currentAbort = null;
let isStreaming = false;
+4
View File
@@ -3277,6 +3277,7 @@ export function addMessage(role, content, modelName, metadata) {
let lastWrap = null;
let firstMsgAi = null;
let lastMsgAi = null;
const newNodes = [];
const toolsByRound = {};
let lastPrivateBrowserUrl = '';
@@ -3370,6 +3371,7 @@ export function addMessage(role, content, modelName, metadata) {
wrap.dataset.raw = txt;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
box.appendChild(wrap);
newNodes.push(wrap);
lastWrap = wrap;
if (!firstMsgAi) firstMsgAi = wrap;
lastMsgAi = wrap;
@@ -3387,6 +3389,7 @@ export function addMessage(role, content, modelName, metadata) {
// Extend line up if there's a chat bubble above
if (txt) threadWrap.classList.add('has-top');
box.appendChild(threadWrap);
newNodes.push(threadWrap);
}
for (const ev of roundTools) {
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
@@ -3448,6 +3451,7 @@ export function addMessage(role, content, modelName, metadata) {
if (image) {
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
box.appendChild(imgBubble);
newNodes.push(imgBubble);
}
}
}
+14 -15
View File
@@ -72,12 +72,7 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
// ── Storage ───────────────────────────────────────────────────────────
function getRecents() {
try {
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
return Array.isArray(values)
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
: [];
}
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
catch { return []; }
}
@@ -90,26 +85,30 @@ function addRecent(hex) {
}
// ── Suggestions based on current color (5 harmony swatches) ──────────
function suggestionsForHsv(h, s, v) {
function computeSuggestions() {
// Complement, analogous ±30°, split-complement (+150), tone shift
return [
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
];
}
// ── Reusable helpers for hosts that surface swatches inline ──────────
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
export function getRecentColors() { return getRecents(); }
export function recordRecentColor(hex) { addRecent(hex); }
export function suggestColorsFor(hex) {
const { h, s, v } = hexToHsv(hex || '#000000');
return suggestionsForHsv(h, s, v);
return [
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
];
}
// ── Popover build ─────────────────────────────────────────────────────
-65
View File
@@ -1,65 +0,0 @@
function streamError(payload) {
const detail = payload?.data ?? payload;
const candidates = [detail?.message, detail?.text, detail?.error?.message,
detail?.error, payload?.message, payload?.text, payload?.error?.message,
payload?.error, typeof payload === 'string' ? payload : null];
const message = candidates.find(value => typeof value === 'string' && value.trim());
return new Error(message || 'Model request failed.');
}
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
export async function consumeCompareEvents(response, onEvent, onActivity) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let line = '', event = 'message', data = [], skipLF = false;
try {
while (true) {
const { done, value } = await reader.read();
if (!done) onActivity?.();
let chunk = decoder.decode(value, { stream: !done });
if (skipLF && chunk.length) {
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
skipLF = false;
}
const separators = /\r\n|\r|\n/g;
let start = 0, separator;
while ((separator = separators.exec(chunk))) {
line += chunk.slice(start, separator.index);
start = separator.index + separator[0].length;
skipLF = separator[0] === '\r' && start === chunk.length;
if (!line) {
if (data.length || event === 'error') {
const raw = data.join('\n');
if (event !== 'error' && raw === '[DONE]') return;
let payload;
try { payload = JSON.parse(raw); }
catch (_) {
if (event === 'error') throw streamError(raw);
throw new Error('Invalid model stream data.');
}
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Invalid model stream data.');
}
await onEvent(payload);
}
event = 'message';
data = [];
} else if (!line.startsWith(':')) {
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'data') data.push(value);
else if (field === 'event') event = value || 'message';
}
line = '';
}
line += chunk.slice(start);
if (done) throw new Error('Stream ended before completion.');
}
} finally {
try { await reader.cancel(); } catch (_) {}
reader.releaseLock();
}
}
+22 -12
View File
@@ -1,6 +1,5 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { consumeCompareEvents } from './sse.js';
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
import markdownModule from '../markdown.js';
@@ -473,9 +472,25 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await consumeCompareEvents(response,
(json) => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
_resetIdleTimeout(); // any chunk = stream is alive
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const json = JSON.parse(data);
if (json.type === 'metrics') {
metrics = json.data;
@@ -657,7 +672,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (hist) hist.scrollTop = hist.scrollHeight;
} else if (json.delta) {
// Skip text deltas if we already rendered an image
if (aiMsgEl._imageData) return;
if (aiMsgEl._imageData) continue;
// Capture TTFT on very first text delta
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
// On first delta, destroy spinner and prepare text area
@@ -686,7 +701,8 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
_scheduleLiveRender(target);
}
} catch (e) { console.warn('Compare stream render error:', e); }
}, _resetIdleTimeout);
}
}
streamOk = true;
// Destroy any remaining spinner
@@ -884,15 +900,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
} else {
console.error('Compare stream error:', error);
awaitingChoice = false;
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
}
} finally {
if (aiMsgEl._spinner) {
aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
clearTimeout(timeoutId);
_timerDone = true;
cancelAnimationFrame(_rafId);
+5 -6
View File
@@ -1195,12 +1195,11 @@ async function _fetchDependencies() {
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
// append a caret button that toggles a per-row recipe panel below.
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
// The caret is the only way to open the recipe panel (the Installed ▾
// menu has no recipe item), and the HW Fit / serve-card deep links
// open it by clicking this caret (#6747).
const recipeCaret = hasRecipe
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
: '';
// Standalone recipe-caret button removed — the "Pick install
// command" action lives inside the Installed ▾ dropdown menu
// (see _showDepMenu) so each row only has ONE caret to click.
// Kept the variable so downstream concat code stays the same.
const recipeCaret = '';
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
// When llama_cpp (or any future engine) reports build_deps_missing
// from its system_prereqs probe, surface a one-tap install button
+7 -44
View File
@@ -1996,17 +1996,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
// next click on a page consumes it. null means clicks do nothing.
let _pdfDropMode = null;
let _pdfAnnotationDragging = false;
function _setPdfAnnotationDragging(dragging) {
_pdfAnnotationDragging = dragging;
for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) {
ref._refreshHandles?.();
if (dragging) {
const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu');
if (menu) menu.style.display = 'none';
}
}
}
// Per-doc last-used line spacing for text annotations. Once the user picks
// 1.6 for one box, every text box dropped after that defaults to 1.6.
const _pdfLastLineHeight = new Map(); // docId -> number
@@ -2301,16 +2290,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// active (toolbar buttons set the mode). Without a mode, clicking does
// nothing — keeps page interactions predictable so users don't get
// surprise boxes from stray clicks.
const isBlankPageTarget = target => target === pageWrap
|| (target?.tagName === 'IMG' && target.parentElement === pageWrap);
let placementStartedOnPage = false;
pageWrap.addEventListener('pointerdown', ev => {
placementStartedOnPage = isBlankPageTarget(ev.target);
}, true);
pageWrap.addEventListener('click', (ev) => {
const canPlace = placementStartedOnPage;
placementStartedOnPage = false;
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
@@ -2442,11 +2423,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Set handle visibility together; clicking/tapping the annotation itself
// brings hidden controls back.
let handlesRequested = _isTouch;
const _setHandlesVisible = (show) => {
if (!_pdfAnnotationDragging) handlesRequested = show;
const dismissed = wrap.dataset.controlsDismissed === '1';
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
const v = (show && !dismissed) ? '' : 'none';
del.style.display = v;
grip.style.display = v;
resize.style.display = v;
@@ -2467,8 +2446,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
_setHandlesVisible(true);
});
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible };
if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for.
@@ -2590,12 +2568,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ref._autoGrow = _autoGrow;
}
del.addEventListener('pointerdown', ev => {
ev.preventDefault();
ev.stopPropagation();
});
del.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_pushPdfUndoSnapshot();
_removeAnnotation(ref);
@@ -2607,7 +2580,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and resize handles while moving so they don't obscure the
// box — easier to see exactly where it lands. Restored on release.
del.style.display = 'none';
@@ -2626,14 +2598,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Drag bottom-right corner to resize. Width/height stored as percentages.
@@ -2642,7 +2611,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and move handles while resizing — clean view of the box edge.
del.style.display = 'none';
grip.style.display = 'none';
@@ -2660,14 +2628,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Text options menu — opened from the floating … button so the spacing
@@ -5232,7 +5197,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
async function _handleAttachUpload(e) {
const files = Array.from(e.target.files || []);
const files = e.target.files;
e.target.value = ''; // reset for next upload
await _uploadComposeFiles(files);
}
@@ -5963,7 +5928,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
async function _handleMarkdownImageUpload(e) {
const files = Array.from(e.target.files || []);
const files = e.target.files;
e.target.value = '';
await _uploadMarkdownImages(files);
}
@@ -15274,8 +15239,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
menu.style.left = 'auto';
const editorPane = document.querySelector('.doc-editor-pane');
menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1));
menu.style.zIndex = '9999';
const langLabel = lang ? lang.toUpperCase() : 'TXT';
// Form-backed markdown doc → primary export is the filled PDF, not the
@@ -15350,8 +15314,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal.
const exportBtn = document.getElementById('doc-footer-export-btn');
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target));
const close = bindMenuDismiss(menu, () => { menu.remove(); });
}
function _richTextExportCss() {
+111 -154
View File
@@ -82,9 +82,10 @@ export function resolveQuickEditAgentModel() {
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
}
// Quick Edit agent (planner) model options. Kept small + explicit;
// dynamically populated from /api/editor/agent-models.
// Quick Edit agent (planner) model options. Kept small + explicit; the local
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
export const QUICK_EDIT_AGENT_OPTIONS = [
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
{ value: 'default', label: 'Server default (hosted planner)' },
];
@@ -140,41 +141,12 @@ export function modelCaps(modelId, endpointName, endpointType) {
return { gen: false, inpaint: false };
}
// Global listeners resolve live controls instead of retaining editor instances.
function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) {
select.value = value;
if (select.value !== value) select.value = '';
updateFooterEditModel();
}
}
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
});
window.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
});
export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) {
const root = container || document;
root._aiModelListeners?.abort();
const listeners = new AbortController();
root._aiModelListeners = listeners;
const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, {
...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal,
});
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
// option — catches clicks regardless of whether loadAIModels has
// rewired the individual select yet and survives any innerHTML
// reset later.
listen(root, 'change', (e) => {
container.addEventListener('change', (e) => {
const sel = e.target.closest('select');
if (!sel) return;
if (sel.value !== '__serve_cookbook__') return;
@@ -184,7 +156,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
openCookbookForImg2img();
});
// Track prior value so we can restore it after the sentinel fires.
listen(root, 'focus', (e) => {
container.addEventListener('focus', (e) => {
const sel = e.target.closest('select');
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
}, true);
@@ -193,31 +165,14 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
const aiCommandSelect = document.getElementById('ge-ai-command-model');
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker: dynamically populates from
// /api/editor/agent-models; restore/save choice from localStorage.
const populateAgentSelect = async () => {
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker is static for now (local VL default,
// server default option); restore/save its choice from localStorage.
const populateAgentSelect = () => {
if (!aiCommandAgentSelect) return;
const prevVal = aiCommandAgentSelect.value;
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
try {
const res = await fetch(`${apiBase}/api/editor/agent-models`);
if (res.ok) {
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const serverModels = data.models || [];
for (const m of serverModels) {
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`;
const label = m.label || m.id;
if (!agentOptions.some(o => o.value === optVal)) {
agentOptions.push({ value: optVal, label });
}
}
}
} catch (_) {}
aiCommandAgentSelect.innerHTML = '';
for (const opt of agentOptions) {
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
const el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.label;
@@ -227,13 +182,11 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
const saved = localStorage.getItem('ge-ai-command-agent-model');
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
aiCommandAgentSelect.value = saved;
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
aiCommandAgentSelect.value = prevVal;
}
} catch {}
if (aiCommandAgentSelect._agentWired !== listeners) {
aiCommandAgentSelect._agentWired = listeners;
listen(aiCommandAgentSelect, 'change', () => {
if (!aiCommandAgentSelect._agentWired) {
aiCommandAgentSelect._agentWired = true;
aiCommandAgentSelect.addEventListener('change', () => {
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
});
}
@@ -243,19 +196,16 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
// select nor the inpaint select nor any per-tool select).
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
!document.querySelector('select.ge-tool-model')) return;
void populateAgentSelect();
populateAgentSelect();
async function loadAIModels(opts = {}) {
try {
const selectBaseUrl = opts.selectBaseUrl || '';
const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/editor/image-models`);
if (listeners.signal.aborted || !root.isConnected) return;
const res = await fetch(`${apiBase}/api/model-endpoints`);
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || [];
const endpoints = await res.json();
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
@@ -265,52 +215,59 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
let selectedGen = null;
let selectedInpaint = null;
const loaded = [];
for (const m of modelsList) {
const modelId = m.id || '';
const caps = modelCaps(modelId, m.provider, m.kind);
const canGen = Boolean(caps.gen || m.kind === 'generate');
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
if (!canGen && !canInpaint) continue;
// Use opaque endpoint_id if available; never fall back to raw base_url
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
const label = `${shortModel}${epHint}`;
const epUsable = true;
loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
if (canGen && aiGenSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
}
if (canInpaint && aiInpaintSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
}
if (canInpaint || canGen) {
for (const ts of perToolSelects) {
for (const ep of endpoints) {
if (!ep.is_enabled) continue;
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
const models = hasListedModels ? sortModelIds(ep.models) : [''];
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
// Image/inpaint endpoints can be called by URL even when their
// /models cache is still empty, so don't strand a freshly served
// Cookbook model as "(offline)" in the editor picker.
const epUsable = !!ep.online || isImageEndpoint;
for (const modelId of models) {
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
if (!caps.gen && !caps.inpaint) continue;
// Encode "<base_url>::<model_id>" so the value carries both pieces.
const value = `${ep.base_url}::${modelId}`;
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
if (caps.gen && aiGenSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
ts.appendChild(opt);
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
}
if (aiCommandSelect) {
if (caps.inpaint && aiInpaintSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiCommandSelect.appendChild(opt);
aiInpaintSelect.appendChild(opt);
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
}
// Per-tool selectors get every img2img-capable entry. Both
// caps.inpaint AND caps.gen models work for harmonize /
// style / upscale (anything that can do img2img).
if (caps.inpaint || caps.gen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
ts.appendChild(opt);
}
if (aiCommandSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiCommandSelect.appendChild(opt);
}
}
}
}
@@ -343,50 +300,32 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
if (aiGenSelect) appendServeSentinel(aiGenSelect);
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
// Sentinel changes are handled by the component's delegated listener.
// Wire the sentinel on the Gen + Inpaint selects too.
const wireServeSentinel = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServeSentinel(aiGenSelect);
wireServeSentinel(aiInpaintSelect);
wireServeSentinel(aiCommandSelect);
if (aiCommandSelect) {
aiCommandSelect._modelsReady = true;
try {
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
let saved = rawSaved;
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
const saved = localStorage.getItem('ge-ai-command-model');
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
} catch {}
if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
}
aiCommandSelect.addEventListener('change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
updateFooterEditModel();
});
}
// Restore each per-tool selection from localStorage.
for (const ts of perToolSelects) {
@@ -398,7 +337,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
}
} catch {}
let prevValue = ts.value;
listen(ts, 'change', () => {
ts.addEventListener('change', () => {
if (ts.value === '__serve_cookbook__') {
ts.value = prevValue;
openCookbookForImg2img();
@@ -411,7 +350,6 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) {
if (listeners.signal.aborted || !root.isConnected) return;
// Fetch failed — still give the user the affordance to set up
// a model. Otherwise the dropdown shows only "Auto" with no
// hint about what to do next.
@@ -420,18 +358,37 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
void populateAgentSelect();
const wireServe = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServe(aiGenSelect);
wireServe(aiInpaintSelect);
wireServe(aiCommandSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
populateAgentSelect();
updateFooterEditModel();
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
}
}
loadAIModels();
if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => {
if (listeners.signal.aborted || !root.isConnected) return;
void loadAIModels({ selectBaseUrl: baseUrl });
void populateAgentSelect();
const onModelEndpointsUpdated = (e) => {
if (!container.isConnected) {
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
return;
}
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
};
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown,
// so a model served via Cookbook mid-edit shows up without having to
// close and reopen the editor. Debounced to one refresh per 3s so
@@ -450,5 +407,5 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
});
};
listen(root, 'mousedown', refreshOnOpen, true);
container.addEventListener('mousedown', refreshOnOpen, true);
}
+7 -10
View File
@@ -181,7 +181,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
// entries in a compact `<select>` overlay reachable from a "Panels"
// button in the layers header, without touching the peek/expand model.
let mobilePanels = null;
if (Array.isArray(extraPanels) && extraPanels.length) {
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) {
const mDock = buildDock({
entries: extraPanels,
persistKey: 'ge-dock-active',
@@ -218,9 +218,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
overlay.hidden = !overlay.hidden;
});
hdr.appendChild(panelsBtn);
if (useDock) panelsBtn.style.display = 'none';
}
if (useDock) overlay.style.display = 'none';
mobilePanels = overlay;
}
@@ -228,7 +226,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
// The panels are only re-parented, so every existing selector and
// listener keeps working; switching tabs just toggles `hidden`.
let dock = null;
if (Array.isArray(extraPanels) && extraPanels.length) {
if (useDock) { rightPanel.classList.add('ge-right-panel--dock');
dock = buildDock({
entries: [
...(Array.isArray(extraPanels) ? extraPanels : []),
@@ -241,12 +239,11 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
onCloseGroup,
});
rightPanel.appendChild(dock.dock);
if (!useDock) dock.dock.style.display = 'none';
// Layers stays pinned below the dock so the layer list is always
// visible while editing (Photoshop-style), regardless of the active
// dock tab. Only the tool options + studio panels live in tabs.
rightPanel.appendChild(layerPanel);
}
if (useDock) {
rightPanel.classList.add('ge-right-panel--dock');
}
rightPanel.appendChild(layerPanel);
// Horizontal drag handle on the LEFT edge of the right panel — drag
// left to widen, right to narrow. Persists chosen width in
@@ -289,5 +286,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
} catch {}
});
return { rightPanel, controls, layerPanel, panelResize, dock, mobilePanels };
return { rightPanel, controls, layerPanel, panelResize, dock };
}
+1 -11
View File
@@ -109,8 +109,6 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
const closeAllFlyouts = () => {
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
toolbar.classList.remove('ge-flyout-open');
toolbar.closest('.ge-toolbar-wrap')?.classList.remove('ge-flyout-open');
};
const openFlyout = (slotEl, face) => {
@@ -118,8 +116,6 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
closeAllFlyouts();
if (isOpen) return;
slotEl.classList.add('open');
toolbar.classList.add('ge-flyout-open');
toolbar.closest('.ge-toolbar-wrap')?.classList.add('ge-flyout-open');
const flyout = slotEl.querySelector('.ge-tool-flyout');
if (!flyout) return;
const r = face.getBoundingClientRect();
@@ -246,13 +242,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
: soft >= 70
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
const icon = document.createElement('span');
icon.className = 'ge-tool-icon';
icon.innerHTML = presetIcon;
const label = document.createElement('span');
label.className = 'ge-tool-flyout-label';
label.textContent = preset.name;
presetItem.append(icon, label);
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
presetItem.addEventListener('click', (ev) => {
ev.stopPropagation();
setInner(face, 'brush');
-1
View File
@@ -535,7 +535,6 @@ export function buildTopbar() {
<span class="ge-stacked-glyph">
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<svg hidden class="ge-save-state-icon ge-save-state-error" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</span>
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
+1 -22
View File
@@ -8,29 +8,8 @@
* @param {HTMLCanvasElement} canvas
* @returns {{x: number, y: number}}
*/
import { state } from './state.js';
let _cachedCanvas = null;
let _cachedRect = null;
let _cachedRectTime = 0;
export function invalidateCanvasCoordsCache() {
_cachedCanvas = null;
_cachedRect = null;
_cachedRectTime = 0;
}
export function canvasCoords(e, canvas) {
const now = performance.now();
let rect;
if (_cachedCanvas === canvas && _cachedRect && (state.drawing || (now - _cachedRectTime < 64))) {
rect = _cachedRect;
} else {
rect = canvas.getBoundingClientRect();
_cachedCanvas = canvas;
_cachedRect = rect;
_cachedRectTime = now;
}
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
+1 -11
View File
@@ -44,7 +44,6 @@
* }} ctx
*/
import { state } from './state.js';
import { invalidateCanvasCoordsCache } from './canvas-coords.js';
import {
applyCanvasPan,
isDirectPanIntent,
@@ -59,12 +58,6 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
canvasWindowBindings?.abort();
canvasWindowBindings = new AbortController();
const { signal } = canvasWindowBindings;
// Invalidate coordinate cache on layout, scroll, resize or orientation shifts
window.addEventListener('resize', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('scroll', invalidateCanvasCoordsCache, { signal, passive: true });
window.addEventListener('orientationchange', invalidateCanvasCoordsCache, { signal, passive: true });
canvasArea.addEventListener('scroll', invalidateCanvasCoordsCache, { passive: true });
let suppressMouseUntil = 0;
// Tool-aware right-click menu on the image and the surrounding area.
wireCanvasContextMenu(canvasArea, { signal });
@@ -97,7 +90,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
window.addEventListener('mousemove', (e) => {
if (Date.now() < suppressMouseUntil) return;
// Only shield the raw-coordinate selection/crop rects from chrome.
if (directGestureActive() && isUiChrome(e)) return;
if (isUiChrome(e) && directGestureActive()) return;
continueDraw(e);
}, { signal });
window.addEventListener('mouseup', (e) => {
@@ -247,14 +240,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
if (Math.abs(newZoom - state.zoom) > 0.001) {
state.zoom = newZoom;
// A pinch is an explicit zoom; layout refits must not undo it.
state.zoomViewMode = 'scale';
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
const label = state.container.querySelector('.ge-zoom-label');
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
syncZoomControls?.();
invalidateCanvasCoordsCache();
onViewportChange?.();
}
const dx = info.cx - multiStartCenter.x;
+1 -2
View File
@@ -77,8 +77,7 @@ export function wireClipboardAndDrop({
// inside the gallery modal's role="dialog" modal-content, so that would
// swallow every paste (including the internal Clipboard + Ctrl+V).
const target = e.target;
// A paste dispatched at window has a non-Node target; contains() would throw.
const insideEditor = target instanceof Node && !!container && container.contains(target);
const insideEditor = target && container && container.contains(target);
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
+5 -18
View File
@@ -50,7 +50,7 @@ import {
import { drawHistogram } from './histogram.js';
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null, schedulePersist = null }) {
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650;
}
@@ -511,7 +511,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
composite();
renderLayerPanel();
closeAdjPopup();
schedulePersist?.();
});
}
@@ -536,14 +535,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
return p.channels[channel];
};
// Params come from project files and drafts, which only validate the
// adjustment type; coerce anything interpolated into markup below.
const finite = value => (Number.isFinite(Number(value)) ? Number(value) : 0);
const hexColor = (value, fallback) => (/^#[0-9a-f]{6}$/i.test(value) ? value : fallback);
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, rawValue, suffix, defaultValue) => {
const value = finite(rawValue);
return `
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
<label>${label}</label>
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
@@ -551,7 +544,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
</div>
`;
};
if (type === 'brightness-contrast') {
const bSlider = Math.round((p.brightness - 1) * 100);
const cSlider = Math.round((p.contrast - 1) * 100);
@@ -700,7 +692,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
});
} else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what.
const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
const cbRow = (key, leftCol, rightCol, label, value) => `
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
@@ -757,9 +749,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'gradient-map') {
const gradientDefaults = defaultAdjParams('gradient-map');
p.shadows = hexColor(p.shadows, gradientDefaults.shadows);
p.highlights = hexColor(p.highlights, gradientDefaults.highlights);
body.innerHTML = `
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
<div class="ge-gradient-color-row">
@@ -800,7 +789,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
body.innerHTML = `
<div class="ge-adj-row">
<label>Color</label>
<input type="color" data-photo-key="color" value="${hexColor(p.color, '#ec8a00')}">
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
</div>
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
@@ -849,7 +838,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
const strength = p.strength == null ? 100 : p.strength;
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded';
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded';
body.innerHTML = `
<label class="ge-adj-channel-field">Lookup preset
<select class="ge-adj-lut-select">
@@ -983,8 +972,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
p.strength = raw; display = raw + '%';
} else if (type === 'white-balance') {
p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') {
p[key] = raw;
} else if (type === 'shadows-highlights') {
+1 -2
View File
@@ -1948,8 +1948,7 @@ export function createLayerPanelRenderer(deps) {
});
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
sName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
sName.lastElementChild.textContent = effectLabel(effect.type);
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`;
sName.title = 'Edit effect (drag onto another layer to copy)';
sName.addEventListener('click', event => {
event.stopPropagation();
+2 -2
View File
@@ -28,10 +28,10 @@ const GRADIENTS = [
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
];
function el(tag, className, text) {
function el(tag, className, html) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
if (html != null) node.innerHTML = html;
return node;
}
+1 -5
View File
@@ -274,11 +274,7 @@ export function createStudioPanels({
return;
}
shapeLayers.slice().reverse().forEach(layer => {
const button = el('button', 'ge-channel-row');
const name = document.createElement('span');
name.className = 'ge-channel-name';
name.textContent = `${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}`;
button.appendChild(name);
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`);
button.type = 'button';
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
pathBody.appendChild(button);
+1 -6
View File
@@ -400,15 +400,10 @@ export const state = {
// Loading-overlay element (whirlpool + label).
editorLoadingEl: null,
// ── Draft persistence & revision tracking ──
// ── Draft persistence ──
draftId: null,
draftName: '',
documentId: null,
documentRevision: 0,
persistingRevision: 0,
savedRevision: 0,
persistRetryTimer: null,
persistRetryCount: 0,
persistTimer: null,
// Current PUT/POST promise so concurrent saves can chain.
persistInFlight: null,
+2 -8
View File
@@ -12,21 +12,15 @@ function toolSettings(tool) {
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
}
let _lastColor = null;
let _lastTransparentColor = null;
function transparentColor(color) {
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
_lastColor = color;
if (color.startsWith('#')) {
const raw = color.slice(1);
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
return _lastTransparentColor;
return `rgba(${rgb.join(',')},0)`;
}
const match = color.match(/rgba?\(([^)]+)\)/);
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
return _lastTransparentColor;
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
}
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
-10
View File
@@ -10,16 +10,6 @@
* the Window > Workspace submenu (wire-extra-menus.js).
*/
export function wireSubmenuToggles(menu) {
menu.addEventListener('mouseover', (e) => {
if (e.target.closest('.ge-menu-parent, .ge-submenu')) {
menu.classList.add('ge-flyout-open');
}
});
menu.addEventListener('mouseleave', () => {
if (!menu.querySelector('.ge-menu-parent.open')) {
menu.classList.remove('ge-flyout-open');
}
});
menu.addEventListener('click', (e) => {
const parent = e.target.closest('.ge-menu-parent');
if (!parent) return;
@@ -241,13 +241,11 @@ export function createTransformSession({
}
if (state.transformPopup) {
try {
state.transformPopup.closest('.ge-controls')?.classList.remove('ge-transform-active');
state.transformPopup.innerHTML = '';
state.transformPopup.style.display = 'none';
} catch {}
state.transformPopup = null;
}
document.querySelectorAll('.ge-controls.ge-transform-active').forEach(c => c.classList.remove('ge-transform-active'));
}
let mobileBoundsHandler = null;
@@ -287,11 +285,6 @@ export function createTransformSession({
// bar) instead of a floating popup over the canvas.
const pop = document.getElementById('ge-transform-section');
if (!pop) return;
const parentControls = pop.closest('.ge-controls');
if (parentControls) {
parentControls.classList.add('ge-transform-active');
parentControls.classList.remove('dismissed');
}
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
const title = pop.querySelector('.ge-adj-title');
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
+2 -2
View File
@@ -127,8 +127,8 @@ export function wireTopbar(deps) {
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
});
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
// Export / Download / Project Save / Project Load.
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
+3 -10
View File
@@ -2650,14 +2650,7 @@ export function openEmailLibrary(opts = {}) {
}
// Wire events
document.getElementById('email-lib-close').addEventListener('click', () => {
const modal = document.getElementById('email-lib-modal');
if (modal?.classList.contains('email-reading') || modal?.querySelector('.doclib-card-expanded')) {
_exitEmailReaderModeForList();
return;
}
closeEmailLibrary();
});
document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
// Clicking the modal header (anywhere except buttons/inputs) collapses
@@ -5028,7 +5021,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
} catch (_) { _syncUnreadTabBadge(0); }
}
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
export async function _loadEmails({ force = false, useCache = true } = {}) {
const seq = ++_libLoadSeq;
state._libLoading = true;
const accountAtStart = state._libAccountId || '';
@@ -5126,7 +5119,7 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
if (!cached && cacheable && !force && !skipFastPath) {
if (!cached && cacheable && !force) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
+10 -18
View File
@@ -73,24 +73,16 @@ function _isDangerousSrcset(value) {
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
export function _escLinkify(text) {
const raw = String(text || '');
// Match raw text once so generated anchors are never linkified again.
const tokenRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])|\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
const parts = [];
let offset = 0;
for (const match of raw.matchAll(tokenRe)) {
parts.push(_esc(raw.slice(offset, match.index)));
const token = match[0];
const isUrl = !!match[1];
const href = isUrl
? (token.startsWith('www.') ? `https://${token}` : token)
: `mailto:${token}`;
const attrs = isUrl ? ' target="_blank" rel="noopener noreferrer"' : '';
parts.push(`<a href="${_attrEsc(_esc(href))}"${attrs}>${_esc(token)}</a>`);
offset = match.index + token.length;
}
parts.push(_esc(raw.slice(offset)));
return parts.join('');
const escaped = _esc(text);
// URLs: http(s)://... or www....
const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
return escaped
.replace(urlRe, (m) => {
const href = m.startsWith('www.') ? `https://${m}` : m;
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
})
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
}
// Pull display name out of "Name <email@x>"; fallback to local-part of
+8 -57
View File
@@ -2229,36 +2229,6 @@ function _makeGalleryDraggable(content) {
// Re-export the manager for the rail click handler
import * as Modals from './modalManager.js';
// Module-owned listeners resolve the current picker; closed modals are never retained.
window.addEventListener('ge-ai-command-model-change', (e) => {
const imageModelSelect = document.getElementById('gallery-image-model');
if (!imageModelSelect || !imageModelSelect._modelsReady) return;
let val = (e && e.detail && typeof e.detail.value === 'string') ? e.detail.value : '';
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
val = '';
}
if (imageModelSelect.value !== val) {
imageModelSelect.value = val;
if (imageModelSelect.value !== val) imageModelSelect.value = '';
}
});
window.addEventListener('storage', (e) => {
const imageModelSelect = document.getElementById('gallery-image-model');
if (e && e.key === 'ge-ai-command-model' && imageModelSelect?._modelsReady) {
let val = e.newValue || '';
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
val = '';
}
if (imageModelSelect.value !== val) {
imageModelSelect.value = val;
if (imageModelSelect.value !== val) imageModelSelect.value = '';
}
}
});
export function openGallery() {
// If already minimized — restore in place, preserve all state
if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) {
@@ -2637,7 +2607,7 @@ export function openGallery() {
// ── Image model (Settings tab) ──
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
// and the SAME localStorage key `ge-ai-command-model` (value `<endpoint_id>::<id>`)
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
// so the two stay in sync.
const _GE_MODEL_KEY = 'ge-ai-command-model';
const imageModelSelect = document.getElementById('gallery-image-model');
@@ -2647,8 +2617,7 @@ export function openGallery() {
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
const data = r.ok ? await r.json() : null;
const models = (data && Array.isArray(data.models)) ? data.models : [];
const rawSaved = localStorage.getItem(_GE_MODEL_KEY) || '';
let saved = rawSaved;
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
imageModelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
@@ -2656,34 +2625,12 @@ export function openGallery() {
imageModelSelect.appendChild(def);
for (const m of models) {
const opt = document.createElement('option');
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
opt.value = endpointKey ? `${endpointKey}::${m.id}` : m.id;
opt.value = `${m.base_url || ''}::${m.id}`;
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
imageModelSelect.appendChild(opt);
}
imageModelSelect._modelsReady = true;
// Invalidate or migrate legacy localStorage values containing URLs or leading ::
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = models.filter(m => m.id === modelPart);
if (matching.length === 1) {
const epKey = matching[0].endpoint_id ? String(matching[0].endpoint_id) : (matching[0].source === 'local' ? 'local' : '');
saved = epKey ? `${epKey}::${matching[0].id}` : matching[0].id;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem(_GE_MODEL_KEY, saved);
else localStorage.removeItem(_GE_MODEL_KEY);
} catch (_) {}
}
imageModelSelect.value = saved;
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
if (imageModelSelect.value !== rawSaved) {
if (imageModelSelect.value) localStorage.setItem(_GE_MODEL_KEY, imageModelSelect.value);
else localStorage.removeItem(_GE_MODEL_KEY);
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: imageModelSelect.value } }));
}
} catch (e) {
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
}
@@ -2698,7 +2645,11 @@ export function openGallery() {
});
_fillImageModels();
}
window.addEventListener('storage', (e) => {
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
imageModelSelect.value = e.newValue || '';
}
});
// ── Tag All Untagged ──
let _tagCancelRequested = false;
+135 -393
View File
@@ -7,7 +7,7 @@ import dragSortModule from './dragSort.js';
import spinnerModule from './spinner.js';
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
import modalManager from './modalManager.js';
import { canvasCoords as _canvasCoords, invalidateCanvasCoordsCache } from './editor/canvas-coords.js';
import { canvasCoords as _canvasCoords } from './editor/canvas-coords.js';
import { pickTopmostLayerAt as _pickTopmostLayerAt } from './editor/layer-hit-test.js';
import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js';
import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js';
@@ -854,13 +854,11 @@ function _setAiProgress(frac, label) {
if (lbl) lbl.textContent = label || '';
}
function _startAiProgress(requestId) {
const endpointId = arguments[1] || '';
_stopAiProgress();
if (!requestId) return;
const tick = async () => {
try {
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`, { credentials: 'same-origin' });
if (!r.ok) return;
const p = await r.json();
const st = p.stage || '';
@@ -1115,68 +1113,6 @@ function _matchAiCommandSuggestions(query) {
.map(hit => hit.item);
}
// On phones the layers panel is a bottom sheet that can cover the editor
// footer (zoom / setup). Float the footer just above the sheet's top edge so
// its controls stay tappable; restore it to normal flow when the sheet is
// collapsed or on desktop.
let _lastFooterDelta = 0;
const _placeMobileFooter = () => {
const footer = document.querySelector('.ge-editor-footer');
if (!footer) return;
const area = document.querySelector('.ge-canvas-area');
const _clear = () => {
footer.classList.remove('ge-footer-floating');
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
footer.style.bottom = ''; footer.style.zIndex = '';
document.body.classList.remove('ge-footer-docked');
state._footerDocked = false;
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
_lastFooterDelta = 0;
// The sheet may have squeezed fit view to a few pixels. Restore fit
// after releasing that constraint, while retaining an explicit zoom.
if (state.zoomViewMode === 'fit') {
try { _fitZoom(); } catch (_) {}
}
}
};
if (window.innerWidth > 700) { _clear(); return; }
const sheet = document.querySelector('.ge-right-panel');
if (!sheet || sheet.classList.contains('minimized') || sheet.classList.contains('expanded')) { _clear(); return; }
const r = sheet.getBoundingClientRect();
const vh = window.innerHeight;
if (r.height <= 0 || r.top >= vh || r.top <= 140) { _clear(); return; }
footer.classList.add('ge-footer-floating');
footer.style.position = 'fixed';
footer.style.left = '0';
footer.style.right = '0';
footer.style.zIndex = '60';
// Dock the footer flush to the sheet top: no seam / dead space between them.
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
document.body.classList.add('ge-footer-docked');
// Dock the canvas image flush too: with the dock padding off, the image
// fills the region so its bottom meets the footer top (no dead space).
state._footerDocked = true;
// Size the canvas region so it ends exactly where the footer begins:
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
// would otherwise keep it running under the footer and the sheet.) The
// ge-footer-docked class also hides the sheet grab handle.
if (area) {
const footerH = footer.getBoundingClientRect().height || 0;
const desiredBottom = Math.round(r.top - footerH);
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
if (want !== _lastFooterDelta) {
area.style.flex = want ? '0 0 auto' : '';
area.style.minHeight = want ? '0px' : '';
area.style.height = want ? want + 'px' : '';
_lastFooterDelta = want;
if (state.zoomViewMode === 'fit') {
try { _fitZoom(); } catch (_) {}
}
}
}
};
function _wireAiCommandBox() {
const wrap = document.getElementById('ge-ai-command');
const toggle = document.getElementById('ge-ai-command-toggle');
@@ -1432,6 +1368,60 @@ function _wireAiCommandBox() {
input.addEventListener('click', _trackLift);
wrap.addEventListener('focusin', _trackLift);
requestAnimationFrame(() => requestAnimationFrame(_liftQuickEdit));
// On phones the layers panel is a bottom sheet that can cover the editor
// footer (zoom / setup). Float the footer just above the sheet's top edge so
// its controls stay tappable; restore it to normal flow when the sheet is
// collapsed or on desktop.
let _lastFooterDelta = 0;
const _placeMobileFooter = () => {
const footer = document.querySelector('.ge-editor-footer');
if (!footer) return;
const area = document.querySelector('.ge-canvas-area');
const _clear = () => {
footer.classList.remove('ge-footer-floating');
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
footer.style.bottom = ''; footer.style.zIndex = '';
document.body.classList.remove('ge-footer-docked');
state._footerDocked = false;
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
_lastFooterDelta = 0;
}
};
if (window.innerWidth > 700) { _clear(); return; }
const sheet = document.querySelector('.ge-right-panel');
if (!sheet || sheet.classList.contains('minimized')) { _clear(); return; }
const r = sheet.getBoundingClientRect();
const vh = window.innerHeight;
if (r.height <= 0 || r.top >= vh) { _clear(); return; }
footer.classList.add('ge-footer-floating');
footer.style.position = 'fixed';
footer.style.left = '0';
footer.style.right = '0';
footer.style.zIndex = '60';
// Dock the footer flush to the sheet top: no seam / dead space between them.
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
document.body.classList.add('ge-footer-docked');
// Dock the canvas image flush too: with the dock padding off, the image
// fills the region so its bottom meets the footer top (no dead space).
state._footerDocked = true;
// Size the canvas region so it ends exactly where the footer begins:
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
// would otherwise keep it running under the footer and the sheet.) The
// ge-footer-docked class also hides the sheet grab handle.
if (area) {
const footerH = footer.getBoundingClientRect().height || 0;
const desiredBottom = Math.round(r.top - footerH);
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
if (want !== _lastFooterDelta) {
area.style.flex = want ? '0 0 auto' : '';
area.style.minHeight = want ? '0px' : '';
area.style.height = want ? want + 'px' : '';
_lastFooterDelta = want;
try { _fitZoom(); } catch (_) {}
}
}
};
// The layers sheet expands/collapses (and its peek height grows as layers
// render) independently of the keyboard; watch it so the bar and the footer
// keep clear of the sheet's top edge as it moves.
@@ -2602,8 +2592,8 @@ function _wireAiCommandBox() {
// done vs retry. The caller enforces a single retry (max 1).
const _vlJudgeAfter = async (prompt, before, after) => {
if (!before || !after) return { done: true };
let ep = '';
let model = 'Qwen3.5-9B';
let ep = 'http://100.69.120.65:8097/v1';
let model = 'Qwen3.5-9B-MLX-8bit';
try {
const info = _getSelectedQuickEditAgentModel();
if (info && info.endpoint) ep = info.endpoint;
@@ -2647,7 +2637,6 @@ function _wireAiCommandBox() {
{ type: 'image_url', image_url: { url: after } },
] }],
};
if (!ep) return { done: true };
try {
const resp = await fetch(ep.replace(/\/$/, '') + '/chat/completions', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
@@ -3306,7 +3295,6 @@ const _adjPopupSystem = createAdjPopupSystem({
saveState: _saveState,
renderLayerPanel: () => _renderLayerPanel(),
getAdjustmentSource: layer => _adjustmentSourceCanvas(layer),
schedulePersist: () => _schedulePersist(),
});
const _closeFxPopup = _adjPopupSystem.closeFxPopup;
const _ensureAdjustments = _adjPopupSystem.ensureAdjustments;
@@ -3960,13 +3948,13 @@ function _finishComposite(render, documentCanvas) {
if (state.transformActive) _drawTransformHandles();
else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay();
if (state.tool === 'pen' || state.penPoints?.length) _penTool.draw();
_penTool.draw();
if (state.cropRect && !state.cropping) _drawCropOverlay();
if (state.cropRect) _syncCropFrame();
_syncCropFrame();
if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
if (!state.drawing) _precisionGuides?.drawDocumentOverlay(state.mainCtx);
_precisionGuides?.drawDocumentOverlay(state.mainCtx);
_refreshSelectionOverlay();
if (!state.drawing) _syncToolClearIndicators();
_syncToolClearIndicators();
}
function composite() {
@@ -4345,14 +4333,7 @@ function _saveState(label, options = {}) {
console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e);
}
try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); }
try {
state.documentRevision = (state.documentRevision || 0) + 1;
state.persistDirty = true;
_setDraftStatus('Unsaved', 'dirty');
if (!_draftSavingDisabled()) {
_schedulePersist();
}
} catch (e) { console.error('[gallery] schedulePersist:', e); }
try { _schedulePersist(); } catch (e) { console.error('[gallery] schedulePersist:', e); }
try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); }
}
@@ -4386,47 +4367,20 @@ function _writeActiveEditorSession() {
function _setDraftStatus(label, stateName = '') {
const el = document.getElementById('ge-draft-status');
if (!el) return;
const validStates = ['saved', 'dirty', 'pending', 'saving', 'error', 'paused'];
let normalizedState = stateName;
if (!validStates.includes(normalizedState)) {
if (stateName === 'saved' || label === 'Saved') normalizedState = 'saved';
else if (stateName === 'error' || /fail|error/i.test(label || '')) normalizedState = 'error';
else if (stateName === 'paused' || /paused/i.test(label || '')) normalizedState = 'paused';
else if (stateName === 'saving' || label === 'Saving…' || label === 'Saving...') normalizedState = 'saving';
else if (stateName === 'pending' || /saving soon/i.test(label || '')) normalizedState = 'pending';
else normalizedState = 'dirty';
}
const defaultLabels = {
saved: 'Saved',
dirty: 'Unsaved',
pending: 'Saving soon…',
saving: 'Saving…',
error: 'Save failed',
paused: 'Save paused',
};
const normalizedLabel = label || defaultLabels[normalizedState] || 'Unsaved';
const normalizedState = stateName === 'saved' ? 'saved' : 'dirty';
const normalizedLabel = normalizedState === 'saved' ? 'Saved' : 'Unsaved';
el.textContent = normalizedLabel;
el.dataset.state = normalizedState;
el.dataset.state = stateName;
const iconHost = el.closest('#ge-save-menu-btn');
const iconKind = (normalizedState === 'error' || normalizedState === 'paused')
? 'error'
: (normalizedState === 'saved' ? 'saved' : 'dirty');
// The icons are <svg>; SVGElement has no `hidden` property, so set the attribute.
iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => {
icon.toggleAttribute('hidden', !icon.classList.contains(`ge-save-state-${iconKind}`));
icon.hidden = !icon.classList.contains(`ge-save-state-${normalizedState}`);
});
const saveButton = document.getElementById('ge-save-menu-btn');
if (saveButton) {
if (normalizedState === 'error' || normalizedState === 'paused') {
saveButton.title = state.persistErrorMessage || `Draft autosave failed: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', `Draft autosave failed: ${normalizedLabel}`);
} else {
saveButton.title = `Draft status: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', `Draft status: ${normalizedLabel}`);
}
saveButton.title = normalizedState === 'error' ? 'Draft autosave needs attention' : `Draft status: ${normalizedLabel}`;
saveButton.setAttribute('aria-label', normalizedLabel);
}
}
try { window.__geSetDraftStatus = _setDraftStatus; } catch (_) {}
function _clearActiveEditorSession() {
try { sessionStorage.removeItem(ACTIVE_EDITOR_SESSION_KEY); } catch (_) {}
@@ -4449,20 +4403,11 @@ function _draftSavingDisabled() {
function _schedulePersist() {
if (_draftSavingDisabled()) return;
if (!state.editorOpen || !state.layers.length) return;
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
if (state.persistTimer) clearTimeout(state.persistTimer);
_setDraftStatus('Saving soon…', 'pending');
// The first save establishes the recovery anchor. Start it immediately
// when no server draft exists; subsequent edits can stay debounced.
const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0;
if (delay > 0) {
state.pendingStatusTimer = setTimeout(() => {
state.pendingStatusTimer = null;
if (state.editorOpen) _setDraftStatus('Saving soon…', 'pending');
}, Math.min(200, Math.floor(delay / 2)));
} else {
_setDraftStatus('Saving soon…', 'pending');
}
state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay);
}
@@ -4646,8 +4591,6 @@ async function _persistDraft() {
// Coalesce concurrent saves — if one's already in-flight, mark dirty
// and let the running call kick off another when it returns.
if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; }
const currentRevision = state.documentRevision || 0;
state.persistingRevision = currentRevision;
const sessionToken = state.editorSessionToken;
const draftIdAtStart = state.draftId || null;
const draftNameAtStart = state.draftName || 'Untitled';
@@ -4675,10 +4618,13 @@ async function _persistDraft() {
};
const serializedBody = JSON.stringify(body);
if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) {
_setDraftStatus('Save paused', 'error');
const err = new Error('Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.');
err.status = 413;
throw err;
const message = 'Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.';
if (isCurrentSession()) {
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 9000);
state.persistErrorMessage = message;
_setDraftStatus('Save paused', 'error');
}
return;
}
const responseError = async (res, method) => {
let detail = '';
@@ -4721,51 +4667,29 @@ async function _persistDraft() {
};
const savePromise = doSave();
state.persistInFlight = savePromise
.catch((e) => {
console.warn('[ge] draft save failed', e);
if (!isCurrentSession()) return;
const message = e?.status === 413
? 'Draft autosave paused: the document exceeds the configured storage limit.'
: `Draft autosave failed: ${e?.message || 'server error'}`;
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 7000);
state.persistErrorMessage = message;
_setDraftStatus('Save failed', 'error');
})
.then((out) => {
if (isCurrentSession()) state.persistInFlight = null;
if (!isCurrentSession()) return;
state.savedRevision = Math.max(state.savedRevision || 0, currentRevision);
state.persistRetryCount = 0;
if (out && out.id) {
state.draftId = out.id;
_writeActiveEditorSession();
}
state.persistErrorMessage = null;
if ((state.documentRevision || 0) > state.savedRevision || state.persistDirty) {
state.persistDirty = true;
_setDraftStatus('Unsaved', 'dirty');
} else {
_setDraftStatus('Saved', 'saved');
}
if (!state.persistErrorMessage) _setDraftStatus('Saved', 'saved');
if (state.persistDirty) {
state.persistDirty = false;
_schedulePersist();
}
})
.catch((e) => {
console.warn('[ge] draft save failed', e);
if (!isCurrentSession()) return;
state.persistDirty = true;
const isPaused = e?.status === 413;
const message = isPaused
? (e?.message || 'Draft autosave paused: the document exceeds the configured storage limit.')
: `Draft autosave failed: ${e?.message || 'server error'}`;
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, isPaused ? 9000 : 7000);
state.persistErrorMessage = message;
_setDraftStatus(isPaused ? 'Save paused' : 'Save failed', isPaused ? 'paused' : 'error');
if (!isPaused && state.editorOpen) {
state.persistRetryCount = Math.min((state.persistRetryCount || 0) + 1, 5);
const retryDelay = Math.min(1000 * Math.pow(2, state.persistRetryCount), 15000);
if (state.persistRetryTimer) clearTimeout(state.persistRetryTimer);
state.persistRetryTimer = setTimeout(() => {
state.persistRetryTimer = null;
if (state.editorOpen && (state.persistDirty || (state.documentRevision || 0) > (state.savedRevision || 0))) {
_persistDraft();
}
}, retryDelay);
}
})
.finally(() => {
if (isCurrentSession()) state.persistInFlight = null;
});
return state.persistInFlight;
}
@@ -5457,24 +5381,7 @@ async function _confirmRasterizeLayer(layer) {
return true;
}
let _lastCursorRect = null;
let _lastCursorRectTime = 0;
function _invalidateCoordsCaches() {
invalidateCanvasCoordsCache();
_lastCursorRect = null;
_lastCursorRectTime = 0;
}
function _beginDraw(e) {
if (state.mainCanvas) {
// Prime canvas rect and cursor rect once before stroke begins
// to avoid synchronous layout reads during pointermove.
_canvasCoords(e, state.mainCanvas);
const now = performance.now();
_lastCursorRect = state.mainCanvas.getBoundingClientRect();
_lastCursorRectTime = now;
}
// Move-tool auto-select (Photoshop parity): when the Auto-Select option is
// on — or Ctrl/Cmd is held for a one-off — pick the topmost visible,
// position-unlocked layer under the pointer before the drag. If the picked
@@ -5521,11 +5428,13 @@ function _beginDraw(e) {
}
if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&
['text', 'shape', 'placed'].includes(layer?.kind)) {
// Paint tools need pixels: prompt for confirmation before destructive rasterization.
// If user cancels, editable text/shape/placed layer is preserved intact.
// If user accepts, layer is converted with an undo boundary so it can be reverted.
void _confirmRasterizeLayer(layer);
return;
// Paint tools need pixels: rasterize the text/shape/placed layer silently
// (it stays undoable) so the very first stroke or pour works, instead of
// bouncing the user through a rasterize dialog that can end up behind the
// canvas where it cannot be clicked. If rasterizing is not possible, fall
// back to the prompt.
if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }
// fall through — `layer` is a pixel layer now, so the tool runs below.
}
// Transform-tool drag (handle grab or move-fallback) — handler in
// editor/tools/transform-drag.js.
@@ -5571,13 +5480,6 @@ function _beginDraw(e) {
_strokeTool.tryBegin(e);
}
let _cursorRaf = null;
let _pendingCursorEvent = null;
const _cursorOverlayTools = new Set([
'eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal',
'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'
]);
function _continueDraw(e) {
if (state.tool === 'pen') return _penTool.drag(e);
if (state.tool === 'path') return _pathTool.drag(e);
@@ -5594,40 +5496,10 @@ function _continueDraw(e) {
// when it's actually over the canvas, otherwise hide it.
const overCanvas = state.mainCanvas && e.target === state.mainCanvas;
if (state.tool === 'eyedropper' && overCanvas) _eyedropperTool.preview(e);
if (_cursorOverlayTools.has(state.tool) && state.mainCanvas) {
const syncCursor = () => {
if (!overCanvas) {
if (state.cursorEl) state.cursorEl.style.display = 'none';
return;
}
if (state.drawing) {
_pendingCursorEvent = e;
if (!_cursorRaf) {
_cursorRaf = requestAnimationFrame(() => {
_cursorRaf = null;
if (_pendingCursorEvent) {
_updateBrushCursor(_pendingCursorEvent);
_pendingCursorEvent = null;
}
});
}
} else {
_updateBrushCursor(e);
}
};
// Mid-stroke, move the cursor only after the stroke has sampled the canvas
// rect: a style write first forces a synchronous style + layout pass of the
// whole editor on every pointer move, which made long strokes lag.
if (state.drawing) {
try { return _continueDrawTools(e); } finally { syncCursor(); }
}
syncCursor();
if (['eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'].includes(state.tool) && state.mainCanvas) {
if (overCanvas) _updateBrushCursor(e);
else if (state.cursorEl) state.cursorEl.style.display = 'none';
}
return _continueDrawTools(e);
}
function _continueDrawTools(e) {
if (state.drawing) return _strokeTool.tryContinue(e);
// Transform-tool hover-cursor + handle drag — handler in
// editor/tools/transform-drag.js. Returns true when the drag is
// consuming the event (rotation / resize); the hover-cursor pass
@@ -5642,13 +5514,17 @@ function _continueDrawTools(e) {
if (state.tool === 'quickselect') return _quickSelectTool.drag(e);
if (state.tool === 'objectselect') return _objectSelectTool.drag(e);
if (state.tool === 'patch') return _patchTool.drag(e);
if (state.moving) return _continueMove(e);
if (state.cropping || state.cropMoving) return _continueCrop(e);
if (!state.drawing) {
if (state.moving) return _continueMove(e);
if (state.cropping || state.cropMoving) return _continueCrop(e);
return;
}
// In-progress stroke (brush / eraser / inpaint / clone) — handler in
// editor/tools/stroke.js.
_strokeTool.tryContinue(e);
}
function _endDraw(e) {
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
if (_pendingCursorEvent) { _updateBrushCursor(_pendingCursorEvent); _pendingCursorEvent = null; }
if (_cursorLoupeVisible) _setCursorLoupe(false);
if (state.tool === 'pen') return _penTool.end();
if (state.tool === 'path') return _pathTool.end(e);
@@ -5667,7 +5543,6 @@ function _endDraw(e) {
// Stroke end (brush / eraser / inpaint / clone) — handler in
// editor/tools/stroke.js.
_strokeTool.tryEnd(e);
_invalidateCoordsCaches();
}
// Floating popup that appears after an inpaint stroke so the user can
@@ -5704,33 +5579,19 @@ const _endStroke = _strokePipeline.endStroke;
// ── Brush cursor overlay ──
function _updateBrushCursor(e) {
if (!state.mainCanvas) return;
const now = performance.now();
let rect;
if (_lastCursorRect && (state.drawing || (now - _lastCursorRectTime < 32))) {
rect = _lastCursorRect;
} else {
rect = state.mainCanvas.getBoundingClientRect();
_lastCursorRect = rect;
_lastCursorRectTime = now;
}
const rect = state.mainCanvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
if (!state.cursorEl) {
let layer = document.getElementById('ge-cursor-layer');
if (!layer) {
layer = document.createElement('div');
layer.id = 'ge-cursor-layer';
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;contain:strict;z-index:2147483647;';
document.body.appendChild(layer);
}
state.cursorEl = document.createElement('div');
state.cursorEl.className = 'ge-brush-cursor';
layer.appendChild(state.cursorEl);
document.body.appendChild(state.cursorEl);
}
// Lasso uses the feather radius (or a sensible default) so the circle
@@ -5752,36 +5613,14 @@ function _updateBrushCursor(e) {
// or responsive layout changes the canvas independently of nominal zoom.
const diameterX = basePx * (rect.width / Math.max(1, state.mainCanvas.width));
const diameterY = basePx * (rect.height / Math.max(1, state.mainCanvas.height));
if (state._lastCursorDiameterX !== diameterX || state._lastCursorDiameterY !== diameterY) {
state._lastCursorDiameterX = diameterX;
state._lastCursorDiameterY = diameterY;
state.cursorEl.style.width = diameterX + 'px';
state.cursorEl.style.height = diameterY + 'px';
}
state.cursorEl.style.width = diameterX + 'px';
state.cursorEl.style.height = diameterY + 'px';
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
// Updating an inline style on every move invalidates document-wide :has()
// selectors. A paused animation keeps the same transform in the rendering
// pipeline without mutating the cursor's style attribute on every frame.
const cursorTransform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
if (!state.cursorEl._positionAnimation || state.cursorEl._positionAnimation.playState === 'idle') {
state.cursorEl._positionAnimation = state.cursorEl.animate(
[{ transform: cursorTransform }, { transform: cursorTransform }],
{ duration: 1, fill: 'both' },
);
state.cursorEl._positionAnimation.pause();
state.cursorEl._positionAnimation.currentTime = 0;
} else {
state.cursorEl._positionAnimation.effect.setKeyframes([{ transform: cursorTransform }, { transform: cursorTransform }]);
}
if (state.cursorEl.style.display !== 'block') {
state.cursorEl.style.display = 'block';
}
state.cursorClientX = clientX;
state.cursorClientY = clientY;
if (!state.drawing) {
state.cursorEl.dataset.clientX = String(clientX);
state.cursorEl.dataset.clientY = String(clientY);
}
state.cursorEl.style.left = (clientX - diameterX / 2) + 'px';
state.cursorEl.style.top = (clientY - diameterY / 2) + 'px';
state.cursorEl.style.display = 'block';
state.cursorEl.dataset.clientX = String(clientX);
state.cursorEl.dataset.clientY = String(clientY);
if (state.tool === 'inpaint') {
// Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also
// flips the cursor so the user sees the effective mode before they
@@ -5802,13 +5641,9 @@ function _updateBrushCursor(e) {
state.cursorEl.style.background = 'rgba(0,0,0,0.15)';
state.cursorEl.style.borderStyle = 'solid';
} else {
const borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
if (state._lastCursorBorderColor !== borderColor) {
state._lastCursorBorderColor = borderColor;
state.cursorEl.style.borderColor = borderColor;
state.cursorEl.style.background = 'transparent';
state.cursorEl.style.borderStyle = 'solid';
}
state.cursorEl.style.borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
state.cursorEl.style.background = 'transparent';
state.cursorEl.style.borderStyle = 'solid';
}
}
@@ -9641,7 +9476,6 @@ function _getFitZoom() {
}
function _applyZoom({ resetPan = false } = {}) {
_invalidateCoordsCaches();
if (!state.mainCanvas) return;
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
@@ -9651,9 +9485,6 @@ function _applyZoom({ resetPan = false } = {}) {
const area = state.container && state.container.querySelector('.ge-canvas-area');
if (resetPan && area && area._resetPan) area._resetPan();
_syncSelectionOverlay();
// The transform overlay is sized and placed for the zoom it was drawn at;
// a refit (e.g. the phone Layers sheet collapsing) must redraw it.
if (state.transformActive) _drawTransformHandles();
_precisionGuides?.redrawRulers();
}
@@ -10506,10 +10337,7 @@ function _buildEditor(container) {
if (e) _eyedropperTool.preview(e);
},
syncZoomControls: () => _syncZoomControls(),
onViewportChange: () => {
_precisionGuides?.redrawRulers();
if (state.transformActive) _drawTransformHandles();
},
onViewportChange: () => _precisionGuides?.redrawRulers(),
onTwoFingerTap: () => undo(),
onThreeFingerTap: () => redo(),
});
@@ -10577,7 +10405,7 @@ function _buildEditor(container) {
composite();
},
});
const { rightPanel, controls, mobilePanels, layerPanel, dock } = buildRightPanel({
const { rightPanel, controls, layerPanel, dock } = buildRightPanel({
controlsHTML: _controlsHTML,
layerPanelHTML: _layerPanelHTML,
extraPanels: [
@@ -10606,7 +10434,6 @@ function _buildEditor(container) {
});
editorBody.appendChild(rightPanel);
panelVisibility.apply();
panelVisibility.subscribe(_invalidateCoordsCaches);
window.gePanelVisibility = panelVisibility;
// Remember the workspace/window layout the user last chose and restore it on
// open, so the editor comes back the way they left it. A fresh session has no
@@ -10675,72 +10502,16 @@ function _buildEditor(container) {
_preflight?.maybeOpen();
} catch (_) {}
try { _initControlKit(container); } catch (_) {}
let _lastLayoutDesktop = null;
const _syncResponsiveLayout = (force = false) => {
_invalidateCoordsCaches();
const isDesktop = window.innerWidth > 700;
if (!force && isDesktop === _lastLayoutDesktop) return;
_lastLayoutDesktop = isDesktop;
const footer = container.querySelector('.ge-editor-footer');
const hdr = container.querySelector('.ge-layers-header');
const fh = container.querySelector('.ge-footer-history');
const fz = container.querySelector('.ge-footer-zoom');
const setupBtn = document.getElementById('ge-setup-btn');
const aiBtn = document.getElementById('ge-ai-settings-btn');
const optionsBar = container.querySelector('.ge-options-bar');
if (isDesktop) {
// Reparent controls into optionsBar
if (controls && optionsBar && controls.parentElement !== optionsBar) {
optionsBar.appendChild(controls);
}
controls?.classList.add('ge-controls--in-options');
controls?.classList.remove('dismissed');
optionsBar?.classList.add('ge-options-bar--has-controls');
// Right panel dock mode
rightPanel?.classList.add('ge-right-panel--dock');
if (dock?.dock) dock.dock.style.display = '';
if (mobilePanels) mobilePanels.style.display = 'none';
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
if (panelsBtn) panelsBtn.style.display = 'none';
// Clear mobile footer docking on desktop
if (fh && footer && fh.parentElement !== footer) {
footer.insertBefore(fh, footer.firstChild);
}
_placeMobileFooter();
} else {
// Reparent controls into editor container (mobile sheet)
if (controls && controls.parentElement !== container) {
container.appendChild(controls);
}
controls?.classList.remove('ge-controls--in-options');
optionsBar?.classList.remove('ge-options-bar--has-controls');
// Right panel mobile sheet mode
rightPanel?.classList.remove('ge-right-panel--dock');
if (dock?.dock) dock.dock.style.display = 'none';
if (mobilePanels) mobilePanels.style.display = '';
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
if (panelsBtn) panelsBtn.style.display = '';
if (fh && hdr && fh.parentElement !== hdr) {
hdr.appendChild(fh);
}
// Dock footer flush to sheet on mobile
_placeMobileFooter();
}
};
_syncResponsiveLayout(true);
try {
const _mobileMql = window.matchMedia('(max-width: 700px)');
_mobileMql.addEventListener('change', () => _syncResponsiveLayout(true));
} catch (_) {}
window.addEventListener('resize', () => _syncResponsiveLayout(false), { passive: true });
// Tool options live in the options bar (Photoshop), not the dock. On
// desktop the controls panel is moved under the contextual strip so the
// dock is reserved for Color / Adjustments / Layers; on mobile
// right-panel.js already re-parents it into a bottom sheet.
if (window.innerWidth > 700 && controls) {
optionsBar.appendChild(controls);
controls.classList.add('ge-controls--in-options');
// The "..." overflow affordance is shown/hidden per tool inside
// renderOptionsBar (it only reveals the secondary brush rows).
}
// Crop options: aspect-ratio presets (Free / Original / 1:1 / 4:5 / 16:9).
// Choosing one locks new crop drags to the ratio and refits an existing
// crop rect (centred, clamped to the canvas). Wired after the controls
@@ -12160,13 +11931,7 @@ function _buildEditor(container) {
const flat = _aiEditSourceCanvas();
const image = flat.toDataURL('image/png').split(',')[1];
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
const qeModel = _getSelectedQuickEditModel();
const endpointId = payload._endpoint || qeModel.endpoint || '';
if (endpointId) {
_startAiProgress(requestId, endpointId);
} else {
_startAiProgress(requestId);
}
_startAiProgress(requestId);
const body = JSON.stringify({ image, request_id: requestId, ...payload });
// The image servers occasionally reject a request transiently (503 while a
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
@@ -13732,11 +13497,6 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled');
state.editorSessionToken += 1;
state.editorOpen = true;
state.documentRevision = 0;
state.persistingRevision = 0;
state.savedRevision = 0;
state.persistRetryCount = 0;
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
_writeActiveEditorSession();
state.layers = [];
state.selectedLayerIds = [];
@@ -13787,8 +13547,6 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
_stopSelectionAnimation();
window.__galleryEditLive = true;
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
state.persistDirty = false;
state.persistErrorMessage = null;
state.persistInFlight = null;
@@ -14035,8 +13793,6 @@ export function closeEditor(options = {}) {
// the editor mid-stroke doesn't lose work. The call is fire-and-forget;
// the server commit lands shortly after the modal hides.
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
if (state.layers.length) {
try { _persistDraft(); } catch {}
}
@@ -14074,21 +13830,7 @@ export function closeEditor(options = {}) {
const h = state.editorDocClickHandlers.pop();
try { document.removeEventListener('click', h); } catch {}
}
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
_pendingCursorEvent = null;
_lastCursorRect = null;
state._lastCursorDiameterX = null;
state._lastCursorDiameterY = null;
state._lastCursorBorderColor = null;
if (state.cursorEl) {
if (state.cursorEl._positionAnimation) {
try { state.cursorEl._positionAnimation.cancel(); } catch {}
state.cursorEl._positionAnimation = null;
}
state.cursorEl.remove();
state.cursorEl = null;
}
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
// Tear down all floating popups + the dock so closing the editor
// doesn't leave stale chips/panels behind on top of the gallery.
try { _closeFxMenu(); } catch {}
+2 -4
View File
@@ -801,8 +801,7 @@ async function _sendRoundRobin(msg, box) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{
role: 'user',
content: `[${m._groupName || m.display}]: ${response}`,
metadata: { group_peer: true, group_model: m._groupName || m.display, model: m.mid }
content: `[${m._groupName || m.display}]: ${response}`
}]}),
});
} catch (e) { console.warn('[group] sync failed:', e); }
@@ -829,8 +828,7 @@ async function _syncAllResponses(holders) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{
role: 'user',
content: `[${model._groupName || model.display}]: ${response}`,
metadata: { group_peer: true, group_model: model._groupName || model.display, model: model.mid }
content: `[${model._groupName || model.display}]: ${response}`
}]}),
});
} catch (e) { /* silent */ }
+2
View File
@@ -379,6 +379,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
root.classList.toggle('keyboard-open', keyboardOpen);
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
else root.style.removeProperty('--keyboard-viewport-height');
if (window.scrollY) window.scrollTo(0, 0);
};
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
// the tapped field already had focus (e.g. the composer after a new chat)
@@ -391,6 +392,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
let _lastVVHeight = window.visualViewport.height;
window.visualViewport.addEventListener('resize', function() {
+3 -5
View File
@@ -163,13 +163,11 @@ function buildCategoryChips() {
const container = document.getElementById('memory-category-filters');
if (!container) return;
const cats = new Set(memories.map(m => m.category || 'fact'));
if (activeCategory !== 'all' && !cats.has(activeCategory)) activeCategory = 'all';
// Hide the chip row entirely when there are no memories — no point showing
// an "all" chip with nothing to filter.
if (!memories.length) { container.innerHTML = ''; return; }
const cats = new Set(memories.map(m => m.category || 'fact'));
const sorted = ['all', ...Array.from(cats).sort()];
const counts = memories.reduce((result, memory) => {
const category = memory.category || 'fact';
@@ -279,7 +277,6 @@ export function openMemoryModal(tabName = 'browse') {
else if (m.default?.loadSkills) m.default.loadSkills(true);
});
}
if (mode === 'memory') return loadMemories();
}
function _decodeMemoryAnchorId(memoryId) {
@@ -300,8 +297,9 @@ function _resetMemoryDeepLinkFilters() {
// Memory search output may expose a shortened id, so prefix matching is
// intentional here.
export async function openMemory(memoryId, attempt = 0) {
openMemoryModal('browse');
_resetMemoryDeepLinkFilters();
await openMemoryModal('browse');
await loadMemories();
if (renderMemoryList) renderMemoryList();
const wanted = _decodeMemoryAnchorId(memoryId);
if (!wanted) return;
+2 -13
View File
@@ -299,18 +299,7 @@ function _renderHistoryMessage(msg, modelName) {
} else {
displayContent = '';
}
let displayRole = msg.role;
// Peer turns remain user-role context for the model, but are agent replies
// in history. Only explicit identity plus its exact prefix can opt in.
if (displayRole === 'user' && meta?.group_peer === true
&& typeof meta.group_model === 'string' && meta.group_model) {
const prefix = `[${meta.group_model}]: `;
if (displayContent.startsWith(prefix)) {
displayRole = 'assistant';
displayContent = displayContent.slice(prefix.length);
}
}
if (displayRole === 'user') {
if (msg.role === 'user') {
displayContent = _stripUserVisionBlocks(displayContent);
const trimmed = displayContent.trim();
if (
@@ -327,7 +316,7 @@ function _renderHistoryMessage(msg, modelName) {
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
}
}
return _addHistoryMessageWithFullRenderer(displayRole, displayContent, modelName, meta);
return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta);
}
function _clearHistoryPager() {

Some files were not shown because too many files have changed in this diff Show More