mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 09:12:24 +01:00
Compare commits
@@ -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
|
||||
|
||||
@@ -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$''',
|
||||
]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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">×</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">×</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">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (``) — 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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/'));
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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">×</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">×</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">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</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.
|
||||
|
||||
@@ -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
@@ -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
@@ -294,14 +294,61 @@ function moreListPayloadFromHref(href) {
|
||||
return _moreListPayloads.get(`${match[1]}:${match[2]}`) || '';
|
||||
}
|
||||
|
||||
// Assistant markdown images (``) 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('://', '://');
|
||||
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);
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user