Compare commits

..
Author SHA1 Message Date
pewdiepie-archdaemon 0b118a9ccc Retain Gallery UI regression coverage in the combined review 2026-10-26 16:57:33 +00:00
pewdiepie-archdaemon 3915efc9ac Merge branch 'sync/pr-6740-dev-20261026' into review/7011-with-dev-20261026 2026-10-26 16:57:23 +00:00
pewdiepie-archdaemon 3ee93b6316 Merge branch 'sync/pr-6741-dev-20261026' into review/7011-with-dev-20261026 2026-10-26 16:57:23 +00:00
pewdiepie-archdaemon 03d0042115 Merge branch 'sync/pr-6738-dev-20261026' into review/7011-with-dev-20261026
# Conflicts:
#	static/index.html
#	static/js/chat.js
#	static/sw.js
2026-10-26 16:57:23 +00:00
pewdiepie-archdaemon 45e23c4615 Merge branch 'sync/pr-6739-dev-20261026' into review/7011-with-dev-20261026 2026-10-26 16:57:09 +00:00
pewdiepie-archdaemon da8ba69c74 Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 2c48e7fdab Verify the Gallery model picker in the UI review that ships it 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon f53ddfba3c Integrate updated preview UI review 2026-10-26 16:37:00 +00:00
pewdiepie-archdaemon 763ae17766 Integrate updated photo-editor client review 2026-10-26 16:37:00 +00:00
pewdiepie-archdaemon e41e8354df Integrate updated Ajax runtime review 2026-10-26 16:37:00 +00:00
pewdiepie-archdaemon 79638043da Integrate updated editor backend review 2026-10-26 16:36:59 +00:00
pewdiepie-archdaemon 76ab72412d Preserve completion gating and handle explicit idle stream status safely 2026-10-26 16:34:56 +00:00
pewdiepie-archdaemon 04ca6c26c3 Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon fdb2a330d9 Remove duplicate session copy-header helpers after integration 2026-10-26 16:30:56 +00:00
pewdiepie-archdaemon e461a6b5c5 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon 22144050fe Update canonical Notes modules alongside their deployment files 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon ace6be44f3 Merge current dev and refresh PR #6740 from the 7011 integration 2026-10-26 16:21:34 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
174 changed files with 2100 additions and 8156 deletions
-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$''',
]
+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.
---
@@ -112,7 +112,7 @@ export function createCardInteractions({
if (state.selectMode) {
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
e.stopPropagation();
tapToEditOrSelect(card);
});
+2 -2
View File
@@ -51,7 +51,7 @@ export function createEditor({
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
<div class="note-form-body">
${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -297,7 +297,7 @@ export function createEditor({
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
+1 -1
View File
@@ -31,7 +31,7 @@ export function createText({
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
+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):
+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}")
+21 -42
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("/")
+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)
+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:
+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."),
-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()
+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())
+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,
-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]:
+2 -3
View File
@@ -1556,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.
+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,
+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))
+25 -50
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_")
@@ -1354,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,
@@ -1644,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.
@@ -1665,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
@@ -1788,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
@@ -2004,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
@@ -2034,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 = []
@@ -2191,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
@@ -2237,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 = (
@@ -2372,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.
@@ -2385,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}")
@@ -2401,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.
@@ -2423,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 -7
View File
@@ -1292,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"]
}
@@ -1545,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"]
@@ -1560,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)"}
}
}
}
@@ -1583,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"]
}
@@ -2000,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:
+40 -33
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;
@@ -5141,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
@@ -5172,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;
@@ -7256,6 +7230,39 @@ body.bg-pattern-ascii-fireflies {
text-align: center;
}
/* Inline assistant markdown images (`![alt](url)`) — rendered by
static/js/markdown.js as a.clickable <a class="chat-image"> wrapper. */
a.chat-image {
display: inline-block;
max-width: 100%;
margin: 0.35rem 0;
}
a.chat-image img.chat-inline-image {
display: block;
max-width: 100%;
height: auto;
border-radius: 8px;
cursor: zoom-in;
}
a.chat-image .chat-image-fallback {
display: none;
font-size: 0.8rem;
color: var(--color-muted, #94a3b8);
word-break: break-all;
padding: 0.5rem 0.75rem;
border: 1px dashed var(--border, #475569);
border-radius: 8px;
background: var(--panel, #202428);
}
a.chat-image.image-broken img.chat-inline-image {
display: none;
}
a.chat-image.image-broken .chat-image-fallback {
display: block;
text-decoration: underline;
cursor: pointer;
}
/* Setup wizard */
.setup-wizard { padding: 16px; max-width: 500px; }
.setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
+14
View File
@@ -22,6 +22,20 @@
/* Session items */
.session-item { position: relative; }
/* Title fade: the server auto-renames a chat shortly after its first
prompt, which re-renders the sidebar. Fade the new title in so the
change reads as smooth rather than a snap. */
.session-item .grow.session-title-updated {
animation: sessionTitleFade 0.5s ease;
}
@keyframes sessionTitleFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.session-item .grow.session-title-updated { animation: none; }
}
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; }
.session-menu-btn:hover { background: none !important; border-color: transparent !important; }
+1
View File
@@ -704,6 +704,7 @@ textarea.memory-add-input {
#serve-bulk-bar,
#gallery-bulk-bar,
#gallery-editor-drafts-bulk,
#notes-bulk-bar,
#memory-modal .memory-bulk-bar {
padding-right: 18px;
}
+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) {
+2 -1
View File
@@ -257,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">
@@ -664,6 +664,7 @@
<div class="theme-fd-group">
<label class="theme-fd-label">Font</label>
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
<option value="gohu">GohuFont</option>
<option value="mono">Monospace</option>
<option value="sans">Sans-serif</option>
<option value="serif">Serif</option>
+61 -1
View File
@@ -18,7 +18,13 @@
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
var ROW_SELECTOR = [
'#sidebar .list-item',
'#user-bar-profile',
'.sidebar-brand',
'.export-dropdown-item',
'.dropdown-item'
].join(',');
// Native interactive descendants. If a row contains one of these we must
// NOT give the row role="button" — a button inside a button is invalid
@@ -55,6 +61,31 @@
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
}
// Settings form labels are written as <label class="settings-label">Text</label>
// with the control as a sibling/descendant, but are not associated via for/id,
// so screen readers cannot name the inputs. Associate each label with its
// control when the mapping is unambiguous (a single form control per row).
var settingsLabelSeq = 0;
function associateSettingsLabels(root) {
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
if (label.hasAttribute('for')) return;
var control = null;
var sib = label.nextElementSibling;
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
control = sib;
} else {
var row = label.closest('.settings-row') || label.parentElement;
if (row) {
var controls = row.querySelectorAll('input,select,textarea');
if (controls.length === 1) control = controls[0];
}
}
if (!control || control.closest('label')) return;
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
});
}
// ---- Modal dialogs -----------------------------------------------------
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and
@@ -118,9 +149,38 @@
el.click();
});
// Disclosure buttons: keep aria-expanded in sync with their controlled
// menu/dropdown, regardless of which module toggles its visibility.
var DISCLOSURE_MAP = [
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
];
function syncDisclosures() {
DISCLOSURE_MAP.forEach(function (d) {
var btn = document.getElementById(d.button);
if (!btn) return;
var menu = document.getElementById(d.menu);
var open = false;
if (menu) {
var cs = window.getComputedStyle(menu);
open = cs.display !== 'none' && menu.offsetWidth > 0;
}
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
function init() {
enhanceAll(document);
enhanceModals(document);
associateSettingsLabels(document);
// Any click (the dropdown toggles, outside-click closes, etc.) may change
// a disclosure's open state; re-sync on the next tick.
document.addEventListener('click', function () {
if (window.setTimeout) setTimeout(syncDisclosures, 0);
});
// Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too.
+7 -5
View File
@@ -763,7 +763,7 @@ function renderFeatherlessPanel(panel, ep, row) {
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
enabledListEl.innerHTML = sortedIds.map(id => {
const displayName = id.split('/').pop() || id;
const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase())
const mode = ['none', 'compact', 'full', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
? String(toolModes[id]).toLowerCase()
: '';
return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
@@ -777,7 +777,8 @@ function renderFeatherlessPanel(panel, ep, row) {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
<a href="#" data-ep-select-none="${epId}">None</a>
</span>
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m => {
const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase())
const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase())
? String(m.tool_mode).toLowerCase()
: '';
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) {
const value = String(sel.value || '').toLowerCase();
const original = String(sel.dataset.originalToolMode || '').toLowerCase();
if (modelId && (sel.dataset.toolModeTouched === '1' || original)) {
modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : '';
modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : '';
}
});
const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length;
+62
View File
@@ -44,6 +44,66 @@ 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>';
// 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 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');
}
// 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;
}
}
// Render the one-click "Open in editor & apply" button on a photo-edit reply
// that had no open editor (photo_editor_action kind 'open_and_apply').
function renderPhotoEditorApplyButton(container, action) {
if (!container || !action || action.kind !== 'open_and_apply') return;
if (container.querySelector('.chat-open-in-editor-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chat-open-in-editor-btn';
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 () => {
btn.disabled = true;
try {
if (typeof window.gePhotoEditorApply !== 'function') {
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
}
await window.gePhotoEditorApply({
imageId: null,
imageUrl: _currentAttachedImageUrl(),
prompt: action.prompt || '',
displayName: 'Chat edit',
});
} catch (err) {
console.error('[chat] open_and_apply failed', err);
btn.disabled = false;
}
});
container.appendChild(btn);
}
let API_BASE = '';
let currentAbort = null;
let isStreaming = false;
@@ -4095,6 +4155,7 @@ import { invalidateSettings } from './appConfig.js';
threadAbove.classList.add('has-bottom');
}
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
uiModule.scrollHistory();
continue;
@@ -6519,6 +6580,7 @@ import { invalidateSettings } from './appConfig.js';
gotDelta = true;
try { spinner.destroy(); } catch (_) {}
renderDelta(json);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
replayRendering.settle();
} else if (json.delta) {
if (!replayRendering.accepts(json)) continue;
+9 -1
View File
@@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) {
btn.className = className;
btn.type = 'button';
btn.title = title;
btn.setAttribute('aria-label', title);
btn.textContent = text;
btn.addEventListener('click', handler);
return btn;
@@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) {
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) {
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -3274,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 = '';
@@ -3367,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;
@@ -3384,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;
@@ -3443,7 +3449,9 @@ export function addMessage(role, content, modelName, metadata) {
for (const ev of roundTools) {
const image = generatedImageResult(ev);
if (image) {
box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id));
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);
}
}
}
+2
View File
@@ -212,6 +212,8 @@ export function handleUIControl(uiData) {
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
if (fn) fn();
}).catch(function(){});
} else if (panel === 'tasks') {
document.getElementById('tool-tasks-btn')?.click();
} else if (panel === 'theme' || panel === 'themes') {
import('./theme.js?v=20260911organsrain1').then(function(mod) {
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
+3 -3
View File
@@ -390,11 +390,11 @@ export function runHTML(code, panel) {
* Main entry point — called when a Run button is clicked
*/
export function run(btn) {
const code = btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const pre = btn.closest('pre');
if (!pre) return;
const codeEl = pre.querySelector('code');
const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const panel = getOrCreatePanel(pre);
if (!code || !code.trim()) {
+18 -17
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,32 @@ 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' },
];
}
// ── Reusable helpers for hosts that surface swatches inline ──────────
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
export function getRecentColors() { return getRecents(); }
export function recordRecentColor(hex) { addRecent(hex); }
export function suggestColorsFor(hex) {
const { h, s, v } = hexToHsv(hex || '#000000');
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, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
];
}
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);
return suggestionsForHsv(h, s, v);
}
// ── Popover build ─────────────────────────────────────────────────────
function buildPopover() {
const p = document.createElement('div');
+2 -1
View File
@@ -1498,7 +1498,8 @@ function _rerenderCachedModels() {
const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join('');
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _minimaxM3Snapshot = '~/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
const _modelPathValue = _savedModelPath || _defaultServeModel;
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
+55 -68
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)' },
];
@@ -166,29 +167,12 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker: dynamically populates from
// /api/editor/agent-models; restore/save choice from localStorage.
const populateAgentSelect = async () => {
// 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();
const serverModels = data.models || [];
for (const m of serverModels) {
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${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;
@@ -198,8 +182,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
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) {
@@ -214,17 +196,16 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
// 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`);
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();
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>';
@@ -234,52 +215,59 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
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; fallback to provider name or base_url for admin
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.base_url || '');
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);
}
}
}
}
@@ -386,7 +374,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
wireServe(aiInpaintSelect);
wireServe(aiCommandSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
void populateAgentSelect();
populateAgentSelect();
updateFooterEditModel();
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
@@ -399,7 +387,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
return;
}
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
void populateAgentSelect();
};
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown,
+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);
+35
View File
@@ -1498,8 +1498,11 @@ let _libSyncStatus = {
source: '',
warming: false,
loading: false,
stale: false,
};
let _libSyncTicker = null;
let _libStaleRefetchTimer = null;
let _libStaleRefetchCount = 0;
function _libSyncDateFrom(value) {
if (!value) return null;
@@ -1554,9 +1557,37 @@ function _setEmailSyncStatus(next = {}) {
if (Object.prototype.hasOwnProperty.call(next, 'loading')) {
_libSyncStatus.loading = Boolean(next.loading);
}
if (Object.prototype.hasOwnProperty.call(next, 'stale')) {
_libSyncStatus.stale = Boolean(next.stale);
}
_renderEmailSyncStatus();
}
// A server-side stale (sync.stale) response means the SWR snapshot was served
// while a background IMAP refresh is still running. Re-fetch in place after a
// short delay (bounded) so the fresh rows land with no skeleton and no scroll
// jump. We only keep retrying while the response is still marked stale.
function _clearStaleEmailRefetch() {
if (_libStaleRefetchTimer) {
clearTimeout(_libStaleRefetchTimer);
_libStaleRefetchTimer = null;
}
_libStaleRefetchCount = 0;
}
function _scheduleStaleEmailRefetch() {
if (_libStaleRefetchTimer || _libStaleRefetchCount >= 3) return;
const folderAtSchedule = state._libFolder;
const accountAtSchedule = state._libAccountId || '';
_libStaleRefetchCount++;
_libStaleRefetchTimer = setTimeout(() => {
_libStaleRefetchTimer = null;
if (document.hidden) return;
if (folderAtSchedule !== state._libFolder || accountAtSchedule !== (state._libAccountId || '')) return;
_loadEmails({ force: false, useCache: false, skipFastPath: true });
}, 2500);
}
function _libCacheKeyFor(accountId, folder, filter, hasAttachments) {
return [
accountId || '',
@@ -5044,11 +5075,15 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
}
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = `${state._libTotal} emails`;
const _isStale = Boolean(sync.stale);
_setEmailSyncStatus({
updatedAt: sync.updated_at || '',
source: sync.source || (cacheSource ? 'client_cache' : ''),
loading: false,
stale: _isStale,
});
if (_isStale) _scheduleStaleEmailRefetch();
else _clearStaleEmailRefetch();
if (cacheable && !cacheSource) {
_libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal, sync });
}
@@ -112,7 +112,7 @@ export function createCardInteractions({
if (state.selectMode) {
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
e.stopPropagation();
tapToEditOrSelect(card);
});
+2 -2
View File
@@ -51,7 +51,7 @@ export function createEditor({
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
<div class="note-form-body">
${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -297,7 +297,7 @@ export function createEditor({
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
+1 -1
View File
@@ -31,7 +31,7 @@ export function createText({
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
+134 -366
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,23 +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);
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
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
@@ -5789,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';
}
}
@@ -9628,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';
@@ -9638,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();
}
@@ -10493,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(),
});
@@ -10564,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: [
@@ -10593,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
@@ -10662,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
@@ -12147,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
@@ -13719,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 = [];
@@ -13774,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;
@@ -14022,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 {}
}
@@ -14062,7 +13831,6 @@ export function closeEditor(options = {}) {
try { document.removeEventListener('click', h); } catch {}
}
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
// 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 {}
+50 -3
View File
@@ -294,14 +294,61 @@ function moreListPayloadFromHref(href) {
return _moreListPayloads.get(`${match[1]}:${match[2]}`) || '';
}
// Assistant markdown images (`![alt](url)`) render inline as a clickable
// thumbnail that opens the full-size image in a new tab. Only https URLs are
// allowed — an http URL (or anything unparseable) is downgraded to its alt
// text rather than rendered as an image. A broken image (the model can
// fabricate a URL) must never leave an empty gap, so each image is paired with
// a hidden fallback caption (alt text + URL) that _bindChatImageFallback()
// reveals when the load fails.
function imageHtml(alt, url, title) {
const safeUrl = safeLinkUrl(url);
if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || '');
const rawUrl = String(url || '').trim();
let safeUrl = '';
try {
const parsed = new URL(rawUrl, window.location.origin);
if (parsed.protocol === 'https:') safeUrl = parsed.href;
} catch (_) {
safeUrl = '';
}
if (!safeUrl) return escapeHtml(alt || rawUrl || '');
const safeAlt = escapeHtml(alt || '');
const safeTitle = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async">`;
const displayUrl = escapeHtml(safeUrl);
const fallbackLabel = escapeHtml(alt || 'Image');
// The fallback caption shows the URL as readable text, but a bare
// `https://` in that text would be caught by the later bare-URL autolink
// pass and wrapped in a nested <a> — which then truncates this outer <a>
// when it is extracted as an allowed-HTML block. Encode the `//` so it
// displays as `https://` yet is invisible to the autolink regex.
const fallbackUrl = displayUrl.replace('://', ':&#47;&#47;');
return (
`<a class="chat-image" href="${displayUrl}" target="_blank" rel="noopener noreferrer" title="Open image in a new tab">` +
`<img class="chat-inline-image" src="${displayUrl}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async" referrerpolicy="no-referrer">` +
`<span class="chat-image-fallback">${fallbackLabel} — ${fallbackUrl}</span>` +
`</a>`
);
}
// Reveal the alt-text + link fallback whenever an inline chat image fails to
// load, and hide it again if a late load succeeds. `error`/`load` on <img> do
// not bubble, so this listens in the capture phase (document → target).
function _bindChatImageFallback() {
if (typeof document === 'undefined' || !document.addEventListener) return;
const wrapOf = (target) => {
if (!target || target.tagName !== 'IMG' || !target.classList || !target.classList.contains('chat-inline-image')) return null;
return target.closest ? target.closest('a.chat-image') : null;
};
document.addEventListener('error', (event) => {
const wrap = wrapOf(event && event.target);
if (wrap) wrap.classList.add('image-broken');
}, true);
document.addEventListener('load', (event) => {
const wrap = wrapOf(event && event.target);
if (wrap) wrap.classList.remove('image-broken');
}, true);
}
_bindChatImageFallback();
function _isModelEndpointUrl(rawUrl) {
try {
const parsed = new URL(String(rawUrl || ''), window.location.origin);
+4 -1
View File
@@ -810,7 +810,10 @@ async function _pick(m) {
_renderLoading('Loading models…');
}
if (window.modelsModule && window.modelsModule.refreshModels) {
_refreshPickerModels({ force: true, showLoading: !hasCache }).then(isLatest => {
// Open instantly from cache; never force a re-probe on open (a dead
// endpoint would otherwise stall every open). The explicit refresh
// button still forces a full re-probe.
_refreshPickerModels({ force: false, showLoading: !hasCache }).then(isLatest => {
if (isLatest && !menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
}).catch(() => {});
+6
View File
@@ -589,6 +589,12 @@ export async function refreshModels(force = false, opts = {}) {
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
}
box.appendChild(noModels);
const openAdmin = noModels.querySelector('#models-open-admin');
if (openAdmin) openAdmin.addEventListener('click', (e) => {
e.preventDefault();
const btn = document.getElementById('user-bar-admin');
if (btn) btn.click();
});
}
} catch (e) {
console.error(e);
+4 -1
View File
@@ -80,7 +80,10 @@ export function init(apiBase) {
// sidebar — _reconnectActive only ran once at load before, so
// agent-started jobs never appeared until a page reload.
if (_activePollInterval) clearInterval(_activePollInterval);
_activePollInterval = setInterval(() => { _reconnectActive(); }, 20000);
_activePollInterval = setInterval(() => {
if (document.hidden) return;
_reconnectActive();
}, 60000);
}
// Allow an immediate adopt when the chat stream signals a new research
+93 -1
View File
@@ -38,6 +38,89 @@ let _historyPager = null;
const _serverStreamChecksInFlight = new Set();
const _serverStreamAbsentUntil = new Map();
const SERVER_STREAM_ABSENT_TTL_MS = 30000;
// Last title we rendered for each session, so the sidebar can fade a title
// in when the server auto-renames a chat (e.g. the spec model titles a new
// chat from its first prompt) instead of snapping to it.
const _prevSessionTitles = new Map();
// === copy-chat header (debug trace block) ===
function _toolSurfaceLabel(mode) {
const m = String(mode == null ? '' : mode).toLowerCase();
if (m === 'full') return 'Regular tools';
if (m === 'compact') return 'Ajax Compact';
if (m === 'spec') return 'Ajax Spec (router)';
if (m === 'none') return 'Tools off';
if (!m) return 'Auto';
return 'unknown';
}
function _formatJst(now) {
const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
const p = (n) => String(n).padStart(2, '0');
return `${jst.getUTCFullYear()}-${p(jst.getUTCMonth() + 1)}-${p(jst.getUTCDate())} ${p(jst.getUTCHours())}:${p(jst.getUTCMinutes())}`;
}
function _buildCopyChatHeader(info) {
const version = info.version || 'unknown';
const build = info.build || 'unknown';
const commit = (info.sourceCommit || 'unknown').slice(0, 8);
const model = info.model || 'unknown';
const endpointUrl = info.endpointUrl || 'unknown';
const toolSurface = info.toolSurface || 'unknown';
const copiedAt = info.copiedAt ? _formatJst(info.copiedAt) : 'unknown';
const sessionId = info.sessionId || 'unknown';
return [
'--- Odysseus info ---',
`Odysseus: ${version} | Harness build: ${build} | Commit: ${commit}`,
`Model: ${model} @ ${endpointUrl} | Tool surface: ${toolSurface}`,
`Copied: ${copiedAt} JST | Session: ${sessionId}`,
'----------------------',
].join('\n');
}
// === end copy-chat header ===
async function _copyChatHeaderFor(s) {
let version = 'unknown';
let build = 'unknown';
let sourceCommit = 'unknown';
try {
const res = await fetch(`${API_BASE}/api/version`);
const v = await res.json();
if (v && typeof v === 'object') {
version = v.version || 'unknown';
build = v.build || 'unknown';
sourceCommit = v.source_commit || 'unknown';
}
} catch (_) {}
let toolSurface = 'unknown';
try {
const res = await fetch(`${API_BASE}/api/model-endpoints`);
if (res.ok) {
const eps = await res.json();
const list = Array.isArray(eps) ? eps : [];
const model = (s && s.model) || '';
const ep = list.find((x) => x && (
(s && s.endpoint_id != null && x.id === s.endpoint_id) ||
(s && s.endpoint_url && x.base_url === s.endpoint_url)
));
if (ep && ep.model_tool_modes && model) {
toolSurface = _toolSurfaceLabel(ep.model_tool_modes[model]);
}
}
} catch (_) {}
return _buildCopyChatHeader({
version,
build,
sourceCommit,
model: (s && s.model) || undefined,
endpointUrl: (s && s.endpoint_url) || undefined,
toolSurface,
sessionId: (s && s.id) || undefined,
copiedAt: new Date(),
});
}
function _shouldPreserveStartupComposer(msgInput) {
if (!msgInput || !msgInput.value) return false;
@@ -684,6 +767,14 @@ function createSessionItem(s) {
let chatTitle = s.name || '';
if (_isFork) chatTitle = chatTitle.replace(/^Fork:\s*/, '').replace(/^\u2ADD\s*/, '');
if (_isGroup) chatTitle = chatTitle.replace(/^\[GRP\]\s*/, '');
// Fade the row's title in only when it actually changed since the last
// render; everything else about the re-render stays silent.
const _prevTitle = _prevSessionTitles.get(s.id);
if (chatTitle && _prevTitle !== undefined && _prevTitle !== chatTitle) {
span.classList.add('session-title-updated');
}
if (chatTitle) _prevSessionTitles.set(s.id, chatTitle);
let label = chatTitle;
if (s.model) label += ' · ' + s.model.split('/').pop();
if (s.archived) label += ' [archived]';
@@ -930,7 +1021,8 @@ function createSessionItem(s) {
const text = typeof m.content === 'string' ? m.content.trim() : JSON.stringify(m.content);
return `${label}: ${text}`;
});
const text = lines.join('\n\n');
const header = await _copyChatHeaderFor(s);
const text = header + '\n\n' + lines.join('\n\n');
try {
await navigator.clipboard.writeText(text);
} catch (_clipErr) {
+6 -18
View File
@@ -106,6 +106,9 @@ export function initSidebarLayout(Storage, opts) {
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
_setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini'));
hamburgerBtn.setAttribute('aria-expanded', String(!sidebarHidden));
const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn');
if (sidebarToggleBtn) sidebarToggleBtn.setAttribute('aria-expanded', String(!sidebarHidden));
}
// Keep incognito button clear of hamburger
const incogBtn = document.getElementById('incognito-btn');
@@ -170,15 +173,6 @@ export function initSidebarLayout(Storage, opts) {
syncRailSide();
}
// Move the closed drawer to the other edge without transitioning its
// transform across the screen, so it then slides in from the edge it opens on.
function _setSidebarSideInstantly(sidebar, right) {
sidebar.style.setProperty('transition', 'none', 'important');
sidebar.classList.toggle('right-side', right);
void sidebar.offsetWidth;
sidebar.style.removeProperty('transition');
}
// Deliberate "open the sidebar" used by the mobile swipe gesture (wired at
// module scope). It MUST set _userToggledSidebar so the auto-collapse
// MutationObserver doesn't immediately re-hide it (the swipe was opening it,
@@ -198,7 +192,7 @@ export function initSidebarLayout(Storage, opts) {
if (side === 'left' || side === 'right') {
const wantRight = side === 'right';
if (sidebar.classList.contains('right-side') !== wantRight) {
_setSidebarSideInstantly(sidebar, wantRight);
sidebar.classList.toggle('right-side', wantRight);
// Swipe-selected sides are temporary on compact/mobile layouts. Do
// not overwrite the user's desktop sidebar preference.
if (window.innerWidth >= 768) {
@@ -236,7 +230,7 @@ export function initSidebarLayout(Storage, opts) {
// Mobile: the hamburger always opens the sidebar from the RIGHT.
// (Not persisted — keeps the desktop side preference untouched.)
if (!sidebar.classList.contains('right-side')) {
_setSidebarSideInstantly(sidebar, true);
sidebar.classList.add('right-side');
if (documentModule && documentModule.swapSide) { try { documentModule.swapSide(); } catch (_) {} }
}
// Opening sidebar — blur keyboard first, then open after layout settles
@@ -321,20 +315,14 @@ export function initSidebarLayout(Storage, opts) {
_wasAutoCollapsed = true;
syncRailSide();
} else if (window.innerWidth >= AUTO_COLLAPSE_WIDTH && isHidden && _wasAutoCollapsed) {
// Only restore if chat won't be too narrow. Suppress the width
// transition and sync the mode classes (ody-sidebar-mini also forces
// width 0) so the reflow measures the sidebar at its full width.
sidebar.style.transition = 'none';
// Only restore if chat won't be too narrow
sidebar.classList.remove('hidden');
syncRailSide();
void document.body.offsetWidth; // reflow
if (chatContainer && chatContainer.offsetWidth < MIN_CHAT_WIDTH) {
sidebar.classList.add('hidden');
} else {
_wasAutoCollapsed = false;
}
void document.body.offsetWidth;
sidebar.style.transition = '';
syncRailSide();
}
}
+25 -2
View File
@@ -5336,6 +5336,29 @@ function _eggRender(html) {
uiModule.scrollHistory();
}
// Delegated handlers for egg commands that used inline onclick (blocked by
// Firefox CSP script-src-attr). Dice re-roll + color copy.
if (!window._eggDelegatedBound) {
window._eggDelegatedBound = true;
document.addEventListener('click', (e) => {
const dice = e.target.closest && e.target.closest('.egg-dice');
if (dice) {
const sides = Math.min(parseInt(dice.dataset.diceSides) || 6, 1000);
dice.style.animation = 'none';
void dice.offsetHeight;
dice.textContent = Math.floor(Math.random() * sides) + 1;
dice.style.animation = 'egg-shake 0.3s ease';
return;
}
const swatch = e.target.closest && e.target.closest('.egg-color-copy');
if (swatch) {
navigator.clipboard.writeText(swatch.dataset.color || '');
swatch.style.transform = 'scale(0.9)';
setTimeout(() => { swatch.style.transform = ''; }, 150);
}
});
}
async function _cmdFlip(args, ctx) {
const isHeads = Math.random() < 0.5;
const edge = Math.random() < 0.001;
@@ -5373,7 +5396,7 @@ async function _cmdRoll(args, ctx) {
const results = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1);
const total = results.reduce((a, b) => a + b, 0);
const dice = results.map((v, i) => {
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" title="d${sides}" onclick="this.style.animation='none';this.offsetHeight;var r=Math.floor(Math.random()*${sides})+1;this.textContent=r;this.style.animation='egg-shake 0.3s ease'">${v}</div>`;
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" class="egg-dice" data-dice-sides="${sides}" title="d${sides}">${v}</div>`;
}).join('');
const totalHtml = count > 1 ? `<div style="font-size:0.8em;opacity:0.5;margin-top:4px">${count}d${sides} = ${total}</div>` : '';
_eggRender(`<div style="display:flex;flex-direction:column;align-items:center;gap:4px"><div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center">${dice}</div>${totalHtml}</div>`);
@@ -5690,7 +5713,7 @@ async function _cmdColor(args, ctx) {
const hex = args[0] || '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0');
const c = hex.startsWith('#') ? hex : '#' + hex;
_eggRender(`<div style="display:flex;align-items:center;gap:12px;animation:egg-fade 0.3s ease-out">
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText('${ctx.esc(c)}');this.style.transform='scale(0.9)';setTimeout(()=>this.style.transform='',150)"></div>
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" class="egg-color-copy" data-color="${ctx.esc(c)}" title="Click to copy"></div>
<div style="display:flex;flex-direction:column;gap:2px"><code style="font-size:1.1em">${ctx.esc(c)}</code>
<span style="font-size:0.75em;opacity:0.4">click swatch to copy</span>
</div>
+21
View File
@@ -34,6 +34,8 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
let tailShownLen = 0; // rendered-text length of the live tail (drives token fade)
let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams
let degraded = !ENABLED; // true once we fall back to full re-render
let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render
let pendingTail = ''; // most recent tail text awaiting a rAF re-render
function start() {
contentEl.textContent = '';
@@ -79,6 +81,24 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
while (holder.firstChild) contentEl.appendChild(holder.firstChild);
}
// Coalesce the per-token tail re-render into at most one render per animation
// frame. Fast providers emit many deltas per frame; the frozen-block pass is
// cheap and stays synchronous, but the markdown re-parse + innerHTML write of
// the still-growing tail only needs to happen once per frame for smooth output.
function scheduleTailRender(tailText) {
pendingTail = tailText;
if (tailRaf !== null) return;
tailRaf = requestAnimationFrame(() => {
tailRaf = null;
// The container may have been rewritten out from under us (chat.js writes
// contentEl.innerHTML for thinking indicators and tool blocks). Skip and
// let the next update() self-heal rather than appending stale tail nodes.
if (tailMarker && tailMarker.parentNode === contentEl) {
renderTail(pendingTail);
}
});
}
// Stream the body of an unterminated code fence by appending only the new
// characters to a stable <pre><code> text node — no re-parse, no re-highlight.
function appendOpenFence(tailText, fence) {
@@ -187,6 +207,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
function finalize() {
if (degraded) return;
try {
if (tailRaf !== null) { cancelAnimationFrame(tailRaf); tailRaf = null; }
if (!started) start();
clearTail();
appendMode = null;
+1 -1
View File
@@ -3185,7 +3185,7 @@ export function openTasks(focusId, opts) {
<div class="modal-header">
<h4 style="position:relative;top:-2px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3L2 6"/><path d="M22 6l-3-3"/></svg>Tasks</h4>
<span style="flex:1"></span>
<button class="close-btn" id="tasks-close">✖</button>
<button class="close-btn" id="tasks-close" aria-label="Close">✖</button>
</div>
<div class="memory-tabs tasks-tabs" role="tablist">
<button class="memory-tab tasks-tab active" data-tab="tasks" role="tab" aria-selected="true">
+1
View File
@@ -40,6 +40,7 @@ const LS_KEY = 'odysseus-theme';
const CUSTOM_THEMES_KEY = 'odysseus-custom-themes';
const FONT_MAP = {
gohu: "'GohuFont', monospace",
mono: "'Fira Code', monospace",
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
serif: "Georgia, 'Times New Roman', serif",
+1 -1
View File
@@ -1,4 +1,4 @@
export const TOOL_WINDOW_SELECTOR = 'body > .modal:not(#styled-confirm-overlay), body > .research-overlay, body > .notes-pane-backdrop';
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop';
export function topToolWindowZ(options = {}) {
const {
+1 -3
View File
@@ -8,7 +8,7 @@ import themeModule from './theme.js?v=20260911organsrain1';
import * as Modals from './modalManager.js';
import spinnerModule from './spinner.js';
import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js';
import { nextToolWindowZ, topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
let toastEl = null;
let autoScrollEnabled = true;
@@ -793,12 +793,10 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
const _prevFocus = document.activeElement;
overlay.classList.remove('hidden');
overlay.style.display = '';
overlay.style.zIndex = String(topPortalZ() + 1000);
function cleanup(result) {
overlay.classList.add('hidden');
overlay.style.display = 'none';
overlay.style.zIndex = '';
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
altBtn.removeEventListener('click', onAlt);
+3 -23
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
const CACHE_NAME = 'odysseus-v1113-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -74,8 +74,6 @@ const PRECACHE = [
'/static/js/fileHandler.js?v=20260909mobileattachmentedit1',
'/static/js/voiceRecorder.js',
'/static/js/actionMenuOrder.js',
'/static/js/platform.js',
'/static/js/modelSort.js',
'/static/js/models.js',
'/static/js/rag.js',
'/static/js/presets.js?v=20260908personaname1',
@@ -366,29 +364,11 @@ self.addEventListener('activate', (e) => {
);
});
// Offline fallback for /static/ JS/CSS. /static is a public, unauthenticated
// mount whose bytes do not depend on the request, but CORS adds `Vary: Origin`
// and module imports send an Origin header that install-time fetches omit, so a
// strict match hides the precached copy. Ignore Vary only for entries in this
// worker's own cache whose sole Vary header is Origin.
async function cachedStaticFile(request) {
const cache = await caches.open(CACHE_NAME);
const exact = await cache.match(request);
if (exact) return exact;
const variesOnlyOnOrigin = response => (response.headers.get('Vary') || '')
.split(',').map(name => name.trim().toLowerCase()).filter(Boolean)
.every(name => name === 'origin');
const candidates = await cache.matchAll(request, { ignoreVary: true });
return candidates.find(variesOnlyOnOrigin) || Response.error();
}
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// Never touch API calls or non-GET.
if (url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;
// Only this origin's own files are cached; anything else goes to the network.
if (url.origin !== self.location.origin) return;
// HTML navigation: network-first app shell — but ONLY for the SPA root.
// Other navigations (e.g. a deep-linked /static/*.html page) must go to the
@@ -414,10 +394,10 @@ self.addEventListener('fetch', (e) => {
fetch(e.request).then(res => {
if (res && res.ok) {
const copy = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy)).catch(() => {});
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy));
}
return res;
}).catch(() => cachedStaticFile(e.request))
}).catch(() => caches.match(e.request))
);
return;
}
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const src = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
const START = '// === copy-chat header (debug trace block) ===';
const END = '// === end copy-chat header ===';
const block = src.slice(src.indexOf(START) + START.length, src.indexOf(END));
const extract = new Function(block + '\nreturn { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst };');
const { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst } = extract();
test('copy-chat header has exact shape and values', () => {
const header = _buildCopyChatHeader({
version: '1.0.3',
build: '20261006.1',
sourceCommit: 'a1b2c3d4e5f6',
model: 'gpt-4o',
endpointUrl: 'https://api.example.com/v1',
toolSurface: 'Ajax Spec (router)',
sessionId: 'sess-123',
copiedAt: new Date(Date.UTC(2026, 9, 13, 5, 30)),
});
const expected = [
'--- Odysseus info ---',
'Odysseus: 1.0.3 | Harness build: 20261006.1 | Commit: a1b2c3d4',
'Model: gpt-4o @ https://api.example.com/v1 | Tool surface: Ajax Spec (router)',
'Copied: 2026-10-13 14:30 JST | Session: sess-123',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('unknown fields are never omitted', () => {
const header = _buildCopyChatHeader({});
const expected = [
'--- Odysseus info ---',
'Odysseus: unknown | Harness build: unknown | Commit: unknown',
'Model: unknown @ unknown | Tool surface: unknown',
'Copied: unknown JST | Session: unknown',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('closing separator is exactly 22 dashes', () => {
const header = _buildCopyChatHeader({});
const lines = header.split('\n');
assert.equal(lines.length, 5);
assert.equal(lines[lines.length - 1], '----------------------');
assert.equal(lines[lines.length - 1].length, 22);
});
test('tool surface labels map modes to human labels', () => {
assert.equal(_toolSurfaceLabel('full'), 'Regular tools');
assert.equal(_toolSurfaceLabel('compact'), 'Ajax Compact');
assert.equal(_toolSurfaceLabel('spec'), 'Ajax Spec (router)');
assert.equal(_toolSurfaceLabel('none'), 'Tools off');
assert.equal(_toolSurfaceLabel(''), 'Auto');
assert.equal(_toolSurfaceLabel(undefined), 'Auto');
assert.equal(_toolSurfaceLabel(null), 'Auto');
assert.equal(_toolSurfaceLabel('weird'), 'unknown');
});
test('JST timestamp is UTC+9 with HH:MM padding', () => {
assert.equal(_formatJst(new Date(Date.UTC(2026, 0, 1, 0, 5))), '2026-01-01 09:05');
assert.equal(_formatJst(new Date(Date.UTC(2026, 11, 31, 15, 0))), '2027-01-01 00:00');
});
+68 -68
View File
@@ -1,10 +1,10 @@
{
"digest": "67c4508478daa8e0",
"digest": "f7e60e4a567c46dc",
"elements": {
"app-shell": {
"app-loader": "f04ad5bf6312d53f",
"attach-strip": "a6474951e2481214",
"body": "4a4b8a158ea6b360",
"body": "4956f449e6a30e93",
"chat-container": "b00a7c6be4e107b1",
"chat-context-pill": "dd905b87245a9a71",
"chat-history": "e65bdc51f4d1675f",
@@ -17,7 +17,7 @@
"export-dropdown-item": "d90f6f513d06a6f7",
"export-dropdown-menu": "0793e06a6aae6a7a",
"hamburger-btn": "34714f6eef3734d8",
"icon-rail": "c800b4ef07f5f1f0",
"icon-rail": "e3f72205260a4a5f",
"icon-rail-btn": "54ffb10e056ebf44",
"incognito-btn": "ee42bd3f6a32576b",
"input-icon-btn": "3d1c773acd84a825",
@@ -42,7 +42,7 @@
"rail-new-chat": "f8f1c2f6be426ab9",
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
"rename-session-modal": "9345624780009d14",
"root": "a6694a5a5dbfbe44",
"root": "a9f7ace2c1cefb24",
"save-custom-preset": "443da70ef5fe9b88",
"scroll-bottom-btn": "24602d72e2949991",
"search-input": "53b94f3e5fdb384d",
@@ -59,7 +59,7 @@
"settings-row": "de568724268cec6e",
"settings-select": "a96e8430b0ec6668",
"settings-sidebar-toggle": "6be9025ab24bc434",
"sidebar": "0dbf510da61afa07",
"sidebar": "77ec42a646a8db66",
"sidebar-brand": "d3d7ef4b21514e80",
"sidebar-new-chat": "567fae30048b7ff3",
"sidebar-resize-handle": "6607b819ee45409d",
@@ -399,10 +399,10 @@
".ge-adj-icon": "405c77944c2f21c7",
".ge-adj-popup": "e1a93233ab11f043",
".ge-adj-row": "fed828d535ba710b",
".ge-adj-row > label": "92541b678e8ddd3e",
".ge-adj-row > label": "21bd09c7cf6167e5",
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
".ge-adj-title": "7609b8e4f862ad23",
".ge-ai-command-collapsed": "5a38c8ab93d1818f",
".ge-ai-command-collapsed": "35a892b0246538ba",
".ge-alpha-badge": "59eb1bb7eddf6df0",
".ge-canvas-area": "b6a7426fcd6ad230",
".ge-canvas-prompt": "97ddee153cdaf1f2",
@@ -430,11 +430,11 @@
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
".ge-layer-group-row .ge-layer-vis": "ac882350a59601a1",
".ge-layer-group-row .ge-layer-vis": "2e6dbbab8d823b4f",
".ge-layer-lock-menu": "528deb6e31022e89",
".ge-layer-lock-option": "38a3da4a8785330a",
".ge-layers-grab": "094e764489b1b069",
".ge-layers-header": "1793db7034e4b67b",
".ge-layers-header": "459d17627f2e83e6",
".ge-layers-list": "0b4bfbd17d414d6d",
".ge-main-canvas": "274650d62945ac12",
".ge-mask-sub-item": "546f03f9c46eb507",
@@ -442,13 +442,13 @@
".ge-section-help": "8bd5ecd734f48ca2",
".ge-shortcuts-grid": "84d3ab6453dab9b3",
".ge-slider-bubble": "2bc54e40357f68cf",
".ge-tool-btn": "67f8bb05e2438d92",
".ge-tool-btn": "fd54cca726e51429",
".ge-tool-sep": "4ae61e4e4eddd504",
".ge-toolbar": "0e05d7a6bc8d85c0",
".ge-topbar": "cdb801be32727e0b",
".ge-toolbar": "ca827cf012ad8863",
".ge-topbar": "6c39a6ade412d5fa",
".ge-transform-field": "76a785f630632652",
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
".ge-transform-popup-body": "af7f9dceddb20374",
".ge-transform-popup-body": "d9d70292e5be7acc",
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
@@ -456,9 +456,9 @@
".ge-transform-popup-suffix": "d55491303f919f61",
".ge-transform-quick-btn": "e74ea16e29b9b001",
".ge-transform-spin": "22dc5f2bfab2ec9a",
".ge-transform-spin button": "ad94130c6f1829d9",
".ge-transform-spin button[data-spin=\"down\"]": "9cb76293b224cf00",
".ge-transform-spin button[data-spin=\"up\"]": "4f6f103a02c3838d",
".ge-transform-spin button": "527810c9f1ac1be5",
".ge-transform-spin button[data-spin=\"down\"]": "c0aff9fcdb755ed2",
".ge-transform-spin button[data-spin=\"up\"]": "4fccf8fbe6c04fa9",
".hamburger": "2b0e4a6cb3333ded",
".hamburger span": "6e2fee4770672a20",
".hamburger-btn": "dc0f91fb82ccd472",
@@ -488,7 +488,7 @@
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
".icon-rail": "c800b4ef07f5f1f0",
".icon-rail": "e3f72205260a4a5f",
".icon-rail-btn": "a5f207c3be15401e",
".incognito-btn": "9833a2e86fab6a94",
".incognito-indicator": "b3945c46844c7278",
@@ -617,15 +617,15 @@
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
".settings-system-logs-tab": "d0d90a13ff327ace",
".sidebar": "0dbf510da61afa07",
".sidebar": "77ec42a646a8db66",
".sidebar .section": "37fc733d59519a08",
".sidebar-brand-title": "2b20f75768b50346",
".sidebar-header": "0d58816ca65b7216",
".sidebar-inner": "ad5e28fedbfeb5db",
".sidebar-notif-dot": "2673e215d0cd84a6",
".sidebar-user-bar": "b77372dbdcb069ef",
".sidebar.hidden": "9021ec7e4e052131",
".sidebar.right-side": "bb6ed4427d07657e",
".sidebar.hidden": "0df301b638e2f93f",
".sidebar.right-side": "2b6bcc7d59f7b6bd",
".skill-card > .skill-card-header": "e2408e762818d2fb",
".skill-card-tags": "a327c85e053e6d3f",
".skill-card-textcol": "f1059ed37c120b60",
@@ -686,56 +686,56 @@
},
"variants": {
"app-shell": {
"desktop-dark-comfortable": "6ccb1b611fff1417",
"desktop-dark-compact": "fa8811351d590698",
"desktop-dark-spacious": "8b23cc34d8dacb5d",
"desktop-light-comfortable": "4a98edc12f46490e",
"desktop-light-compact": "3712d798c576cf07",
"desktop-light-spacious": "4aee43267423df1f",
"laptop-dark-comfortable": "35682719e8aeea91",
"laptop-dark-compact": "a113f77e47ef20d6",
"laptop-dark-spacious": "a94cb0602b8ebf8d",
"laptop-light-comfortable": "b523ba8c4d5f5180",
"laptop-light-compact": "aff4e1e8a5c192a8",
"laptop-light-spacious": "dc83f76cba448444",
"phone-dark-comfortable": "10af205a321eb35d",
"phone-dark-compact": "1f2816f672a2ff9f",
"phone-dark-spacious": "10af205a321eb35d",
"phone-light-comfortable": "b781b1eec0f33d3e",
"phone-light-compact": "131a7b11605617bb",
"phone-light-spacious": "b781b1eec0f33d3e",
"tablet-dark-comfortable": "4e005f33938a3026",
"tablet-dark-compact": "3a389740245ce8e9",
"tablet-dark-spacious": "4e005f33938a3026",
"tablet-light-comfortable": "e7d5a710864503d3",
"tablet-light-compact": "e65e1bcd50cebf33",
"tablet-light-spacious": "e7d5a710864503d3"
"desktop-dark-comfortable": "0c9123117135e362",
"desktop-dark-compact": "c694f5d5118ac0e2",
"desktop-dark-spacious": "90a67613e8cfdf5d",
"desktop-light-comfortable": "17727a9945ccc77b",
"desktop-light-compact": "2a887c30d231e357",
"desktop-light-spacious": "94b4666734b87625",
"laptop-dark-comfortable": "26d334b139b653f2",
"laptop-dark-compact": "fea30bad6824357d",
"laptop-dark-spacious": "4fdb28deaaaec870",
"laptop-light-comfortable": "c7fc986a7e70aac8",
"laptop-light-compact": "147645431229ddff",
"laptop-light-spacious": "d513c285d30c8289",
"phone-dark-comfortable": "551761344bec8d09",
"phone-dark-compact": "293d66518c3ec994",
"phone-dark-spacious": "551761344bec8d09",
"phone-light-comfortable": "cb41acd3f9af3578",
"phone-light-compact": "dbef6e9e373e9c10",
"phone-light-spacious": "cb41acd3f9af3578",
"tablet-dark-comfortable": "0f682621b4ead393",
"tablet-dark-compact": "e6f3fece5e716a2c",
"tablet-dark-spacious": "0f682621b4ead393",
"tablet-light-comfortable": "6e398df9ca69c58b",
"tablet-light-compact": "dca75981c8675bfa",
"tablet-light-spacious": "6e398df9ca69c58b"
},
"bench": {
"desktop-dark-comfortable": "48a5eeb1259fd422",
"desktop-dark-compact": "cef4e9f2ceacefbb",
"desktop-dark-spacious": "d87bef476928e845",
"desktop-light-comfortable": "69a68072200b3669",
"desktop-light-compact": "9c6deaecd2265ad9",
"desktop-light-spacious": "8a355029e7261110",
"laptop-dark-comfortable": "aaf69c3500c38635",
"laptop-dark-compact": "ca5a5cb542ee1901",
"laptop-dark-spacious": "9f85415f735e040e",
"laptop-light-comfortable": "06caf416811d5778",
"laptop-light-compact": "f2bab4d31395dd49",
"laptop-light-spacious": "6c56a4469a2b42ff",
"phone-dark-comfortable": "eb27d09bf0ff4ca1",
"phone-dark-compact": "e60d82de1d36c09b",
"phone-dark-spacious": "3578747ada4bcbb2",
"phone-light-comfortable": "8b0b9f1e7d73d36f",
"phone-light-compact": "d2f58cb1c1675907",
"phone-light-spacious": "edae71cdde26fd58",
"tablet-dark-comfortable": "8fc2eb75bed6417d",
"tablet-dark-compact": "2fd7fb77dfb379ba",
"tablet-dark-spacious": "1376b658cfc7cb4b",
"tablet-light-comfortable": "b456d13e3bbf627e",
"tablet-light-compact": "44eb1164a0db4654",
"tablet-light-spacious": "5d999940aa547f86"
"desktop-dark-comfortable": "a9709cb47eec7544",
"desktop-dark-compact": "bfab96cb72fd6faf",
"desktop-dark-spacious": "25469178b443428a",
"desktop-light-comfortable": "2166794d643fae4c",
"desktop-light-compact": "81d8ab64407b1d9a",
"desktop-light-spacious": "d6d19f33476665af",
"laptop-dark-comfortable": "57169e64e1fb5324",
"laptop-dark-compact": "6ac903cd8a711bcf",
"laptop-dark-spacious": "a47535332e9c5de7",
"laptop-light-comfortable": "1e2b381de3556fac",
"laptop-light-compact": "6dbfb465121949c1",
"laptop-light-spacious": "1b8bfcd4bc4ed214",
"phone-dark-comfortable": "67ca3c78e6b9495a",
"phone-dark-compact": "ad6f3a9f5c8d81cd",
"phone-dark-spacious": "1e6f66e56519ba5d",
"phone-light-comfortable": "ef876073cc2dc45e",
"phone-light-compact": "2ef6f5dc7a646be9",
"phone-light-spacious": "b0d4cd9bd0361966",
"tablet-dark-comfortable": "58922240b216e786",
"tablet-dark-compact": "3991c22f99a394e0",
"tablet-dark-spacious": "9848c3b7debac974",
"tablet-light-comfortable": "12dfbad36172de20",
"tablet-light-compact": "80d4843e622f1353",
"tablet-light-spacious": "c420b8d46c991a44"
},
"login": {
"desktop-dark-comfortable": "3cf4809db0f8e119",
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
try {
for (const width of [390, 900]) {
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?mobile-export-regression=1');
mod.init('/api');
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
});
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
await page.evaluate(() => {
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
});
await page.locator('#doc-footer-export-btn').click();
const menu = page.locator('#doc-export-menu');
await menu.waitFor({ state: 'visible' });
const bounds = await menu.boundingBox();
assert(bounds.x >= 0 && bounds.y >= 0);
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
// A second click closes the menu, then reopening must still allow export.
await page.locator('#doc-footer-export-btn').click();
assert.equal(await menu.count(), 0);
await page.locator('#doc-footer-export-btn').click();
const pending = page.waitForEvent('download');
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
const download = await pending;
assert(download.suggestedFilename().endsWith('.md'));
assert.equal(await download.failure(), null);
const stream = await download.createReadStream();
let content = '';
for await (const chunk of stream) content += chunk;
assert.equal(content, '# Mobile export\n\nActual document content.');
await page.close();
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
}
} finally {
await browser.close();
}
+2 -7
View File
@@ -1,7 +1,7 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated Odysseus server on port
`7013` with a temporary SQLite database and disposable authenticated account.
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
```bash
npm install
@@ -13,8 +13,3 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const { selectTool } = require('./helpers.js');
const {
dragOnCanvas,
encodedImagePixelDigest,
@@ -11,7 +11,7 @@ const {
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-fx').click();
await page.locator('#ge-add-layer').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
@@ -252,7 +252,8 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
await chooseFilterAction(page, 'effect-blur-gaussian');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -281,7 +282,8 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await chooseFilterAction(page, 'effect-preset-crisp-detail');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
@@ -306,14 +308,16 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up();
await chooseFilterAction(page, 'effect-color-overlay');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click();
await chooseFilterAction(page, 'effect-drop-shadow');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -344,7 +348,8 @@ test('retained Stroke uses layer alpha and persists its controls', async ({ page
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await chooseFilterAction(page, 'effect-stroke');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -1,9 +1,5 @@
const { test, expect } = require('@playwright/test');
// These tests borrow /login as a lightweight page. A signed-in session would
// make the login page redirect to the app mid-test, so run them signed out.
test.use({ storageState: { cookies: [], origins: [] } });
async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) {
+3 -11
View File
@@ -54,8 +54,7 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up();
const strokeDuration = Date.now() - startedAt;
expect(strokeDuration).toBeLessThan(5000);
expect(Date.now() - startedAt).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
@@ -79,17 +78,14 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
await selectTool(page, 'eyedropper');
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
// The options strip height differs per tool, which re-centres the canvas a
// few pixels; hover the same image point that was painted.
const painted = await canvasPoint(page, 0.3, 0.3);
await page.mouse.move(painted.x, painted.y);
await page.mouse.move(sample.x, sample.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(painted.x, painted.y);
await page.mouse.click(sample.x, sample.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) {
@@ -182,11 +178,7 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await selectTool(page, 'smudge');
// Smudge strength lives in the brush-family "More" menu with the other
// per-tool stroke modifiers.
await page.locator('#ge-brush-more').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.keyboard.press('Escape');
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
@@ -1,6 +1,6 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
@@ -75,15 +75,13 @@ test('touch crop and selection gestures complete through the shared lifecycle',
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await selectTool(page, 'crop');
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
// aim at where the canvas settles, as a finger would.
const canvasBox = await settledCanvasBox(page);
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
@@ -115,24 +113,18 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await selectTool(page, 'brush');
// Re-tapping the active tool collapses its controls sheet (canvas-first
// phone editing), which also uncovers the Layers sheet grab handle.
await selectTool(page, 'brush');
const canvasBox = await settledCanvasBox(page);
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
// History buttons live in the Layers sheet, which the tool pick minimized.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
@@ -39,7 +39,7 @@ async function downloadFormat(page, format, filename) {
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-fx').click();
await page.locator('#ge-add-layer').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
@@ -100,7 +100,7 @@ test('composited correction exports preserve pixels and format metadata', async
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-fx').click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
+5 -3
View File
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const { selectTool } = require('./helpers.js');
const fs = require('node:fs');
const {
compareExportPixels,
@@ -38,7 +38,8 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -83,7 +84,8 @@ test('group retained effects export the visible composite and survive reopen', a
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
+2 -1
View File
@@ -10,6 +10,7 @@ test('group drag reorders the complete subtree with undo and server persistence'
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
@@ -65,7 +66,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-title').click();
await page.locator('.ge-layers-header').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
+37 -60
View File
@@ -1,22 +1,40 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Workflow tests exercise the canvas, not the optional setup overlay.
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = path => import(path);
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
@@ -350,51 +368,12 @@ async function selectTool(page, toolId) {
await face.first().click();
return;
}
// Open the flyout via its corner triangle, wait for item visibility, and click realistically
const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
const tri = slot.locator('.ge-tool-flyout-tri');
if (await tri.count() > 0) {
await tri.click();
}
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
await expect(item).toBeVisible({ timeout: 3000 });
await item.click();
}
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
// Wait for the canvas to stop moving before aiming at it.
async function settledCanvasBox(page) {
let box = null;
await expect.poll(async () => {
const previous = box;
box = await page.locator('.ge-main-canvas').boundingBox();
return JSON.stringify(previous) === JSON.stringify(box);
}).toBe(true);
return box;
}
// Rare text/shape options live in the options strip's "More" popover, which
// closes on any outside press. Open it (idempotently) before using them.
async function openOptionsMore(page, kind) {
const menu = page.locator(`#ge-${kind}-more`);
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
await expect(menu).toBeVisible();
}
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
// Open the menu, then the item's parent row, as a user would.
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
await page.locator(opener).click();
const menu = page.locator('#ge-filter-menu');
await expect(menu).toBeVisible();
const item = menu.locator(`[data-filter-action="${action}"]`);
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
await item.click();
// Flyout members are pre-rendered (hidden until the slot opens). Dispatch a
// click directly so the helper does not depend on hover/flyout visibility.
await page.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`).first().dispatchEvent('click');
}
module.exports = {
chooseFilterAction,
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
@@ -402,9 +381,7 @@ module.exports = {
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
openOptionsMore,
reopenDraft,
selectTool,
settledCanvasBox,
waitForDraft,
};

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