mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-28 00:32:22 +01:00
fix(editor): harden bridge isolation and completion semantics
This commit is contained in:
@@ -119,19 +119,26 @@ Enabled automatically when the editor opens (`window.__geEditorBridge`).
|
|||||||
- The page runs the op via the control API and replies:
|
- The page runs the op via the control API and replies:
|
||||||
`POST /api/editor/bridge/result {id,ok,result,error,screenshot,ms}`.
|
`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}`.
|
- Enqueue a command (server side): `POST /api/editor/bridge/command {session, op|steps, wait, timeout}`.
|
||||||
With `wait:true` the call blocks and returns the editor's result.
|
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.
|
||||||
|
|
||||||
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
Round-trip example (authenticated; `<session>` = `window.__geEditorBridge.session`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
curl -s -X POST http://127.0.0.1:7011/api/editor/bridge/command \
|
||||||
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
-H 'Content-Type: application/json' -b "odysseus_session=$TOKEN" \
|
||||||
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
|
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8}'
|
||||||
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
Session scoping is `owner:session`, so an agent can only drive editors owned by the
|
Session scoping uses the authenticated owner and session as separate values. A
|
||||||
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3474,7 +3474,7 @@ def setup_chat_routes(
|
|||||||
if not _privs.get("can_use_documents", True):
|
if not _privs.get("can_use_documents", True):
|
||||||
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
|
||||||
if not _privs.get("can_generate_images", True):
|
if not _privs.get("can_generate_images", True):
|
||||||
disabled_tools.update({"generate_image", "edit_image"})
|
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
|
||||||
if not _privs.get("can_manage_memory", True):
|
if not _privs.get("can_manage_memory", True):
|
||||||
disabled_tools.update({"manage_memory", "manage_skills"})
|
disabled_tools.update({"manage_memory", "manage_skills"})
|
||||||
if not _privs.get("can_use_research", True):
|
if not _privs.get("can_use_research", True):
|
||||||
|
|||||||
+216
-84
@@ -21,11 +21,12 @@ import asyncio
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from src import editor_style_guide as style_guide
|
from src import editor_style_guide as style_guide
|
||||||
@@ -1169,16 +1170,66 @@ def _normalize_steps(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
op = str(step.get("op") or "")
|
op = str(step.get("op") or "")
|
||||||
if op not in _OP_SET:
|
if op not in _OP_SET:
|
||||||
continue
|
continue
|
||||||
out.append({"op": op, "params": step.get("params") or {}})
|
params = step.get("params")
|
||||||
|
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _user(request: Request) -> Optional[str]:
|
async def _json_object(request: Request) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
from src.auth_helpers import get_current_user
|
body = await request.json()
|
||||||
return get_current_user(request)
|
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:
|
||||||
|
try:
|
||||||
|
from core.middleware import require_admin
|
||||||
|
require_admin(request)
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _user(request: Request) -> Optional[str]:
|
||||||
|
# These owner-scoped editor APIs have no delegated-token scope contract.
|
||||||
|
# Do not collapse all bearer users into the shared "api" pseudo-owner.
|
||||||
|
if getattr(request.state, "api_token", False):
|
||||||
|
raise HTTPException(403, "API tokens must use a scope-aware API route")
|
||||||
|
from src.auth_helpers import get_current_user, _auth_disabled
|
||||||
|
u = get_current_user(request)
|
||||||
|
if u:
|
||||||
|
return u
|
||||||
|
if _auth_disabled():
|
||||||
return None
|
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]:
|
async def _default_editor_planner(messages: List[dict], owner: Optional[str]) -> Optional[str]:
|
||||||
@@ -1206,17 +1257,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
if _EDITOR_PLANNER is None:
|
if _EDITOR_PLANNER is None:
|
||||||
set_editor_planner(_default_editor_planner)
|
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.
|
# 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
|
# 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
|
# 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.
|
# command could be consumed by a stale subscriber while the active page got nothing.
|
||||||
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
|
subscribers: Dict[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
|
||||||
backlog: Dict[str, List[dict]] = {}
|
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
|
||||||
pending: Dict[str, asyncio.Future] = {}
|
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
|
||||||
|
max_pending = 1024
|
||||||
|
|
||||||
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
def _subscribe(session: str) -> "asyncio.Queue[dict]":
|
||||||
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
q: "asyncio.Queue[dict]" = asyncio.Queue(maxsize=_QUEUE_MAX)
|
||||||
@@ -1253,17 +1301,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
del bl[0: len(bl) - _QUEUE_MAX]
|
del bl[0: len(bl) - _QUEUE_MAX]
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
|
def _expire_command(session, cid, fut):
|
||||||
return f"{owner or 'anon'}:{session or 'default'}"
|
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]}"
|
||||||
|
|
||||||
@router.post("/api/editor/plan")
|
@router.post("/api/editor/plan")
|
||||||
async def plan(request: Request) -> JSONResponse:
|
async def plan(request: Request) -> JSONResponse:
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
prompt = _prompt(body.get("prompt"))
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
prompt = str(body.get("prompt") or "").strip()
|
|
||||||
state = body.get("state") or {}
|
state = body.get("state") or {}
|
||||||
mode = str(body.get("mode") or "plan").strip().lower()
|
mode = str(body.get("mode") or "plan").strip().lower()
|
||||||
screenshot = body.get("screenshot")
|
screenshot = body.get("screenshot")
|
||||||
@@ -1363,31 +1424,49 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
@router.post("/api/editor/bridge/command")
|
@router.post("/api/editor/bridge/command")
|
||||||
async def bridge_command(request: Request) -> JSONResponse:
|
async def bridge_command(request: Request) -> JSONResponse:
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
session = _scoped(body.get("session"), owner)
|
session = _scoped(body.get("session"), owner)
|
||||||
steps = _normalize_steps(body)
|
steps = _normalize_steps(body)
|
||||||
wait = bool(body.get("wait"))
|
wait = bool(body.get("wait"))
|
||||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
timeout = _bridge_timeout(body.get("timeout"))
|
||||||
ids: List[str] = []
|
ids: List[str] = []
|
||||||
wait_ids: List[str] = []
|
if len(pending) + len(steps) > max_pending:
|
||||||
|
raise HTTPException(429, "Too many pending editor commands")
|
||||||
for step in steps:
|
for step in steps:
|
||||||
cid = uuid.uuid4().hex
|
cid = uuid.uuid4().hex
|
||||||
ids.append(cid)
|
ids.append(cid)
|
||||||
if wait:
|
fut = asyncio.get_event_loop().create_future()
|
||||||
pending[cid] = asyncio.get_event_loop().create_future()
|
pending[cid] = (owner, fut)
|
||||||
wait_ids.append(cid)
|
|
||||||
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
_broadcast(session, {"id": cid, "op": step["op"], "params": step["params"]})
|
||||||
if wait and len(wait_ids) == 1:
|
|
||||||
|
if wait and len(steps) == 1:
|
||||||
|
cid = ids[0]
|
||||||
|
entry = pending.get(cid)
|
||||||
|
fut = entry[1]
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
|
res = await asyncio.wait_for(fut, timeout=timeout)
|
||||||
return JSONResponse(result)
|
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})
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(wait_ids[0], None)
|
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
|
finally:
|
||||||
return JSONResponse({"queued": ids, "session": session})
|
_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,
|
||||||
|
})
|
||||||
|
|
||||||
@router.get("/api/editor/bridge/events")
|
@router.get("/api/editor/bridge/events")
|
||||||
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
async def bridge_events(request: Request, session: str = "default") -> StreamingResponse:
|
||||||
@@ -1412,38 +1491,50 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
|
|
||||||
@router.post("/api/editor/bridge/result")
|
@router.post("/api/editor/bridge/result")
|
||||||
async def bridge_result(request: Request) -> JSONResponse:
|
async def bridge_result(request: Request) -> JSONResponse:
|
||||||
try:
|
owner = _user(request)
|
||||||
body = await request.json()
|
body = await _json_object(request)
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
cid = str(body.get("id") or "")
|
cid = str(body.get("id") or "")
|
||||||
fut = pending.pop(cid, None)
|
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
|
||||||
if fut is not None and not fut.done():
|
if fut is not None and not fut.done():
|
||||||
|
is_ok = bool(body.get("ok"))
|
||||||
fut.set_result({
|
fut.set_result({
|
||||||
"id": cid, "ok": bool(body.get("ok")),
|
"id": cid,
|
||||||
"result": body.get("result"), "error": body.get("error"),
|
"ok": is_ok,
|
||||||
|
"status": "completed" if is_ok else "failed",
|
||||||
|
"completed": is_ok,
|
||||||
|
"result": body.get("result"),
|
||||||
|
"error": body.get("error"),
|
||||||
"screenshot": body.get("screenshot"),
|
"screenshot": body.get("screenshot"),
|
||||||
})
|
})
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
@router.post("/api/editor/bridge/await")
|
@router.post("/api/editor/bridge/await")
|
||||||
async def bridge_await(request: Request) -> JSONResponse:
|
async def bridge_await(request: Request) -> JSONResponse:
|
||||||
try:
|
owner = _user(request)
|
||||||
body = await request.json()
|
body = await _json_object(request)
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
cid = str(body.get("id") or "")
|
cid = str(body.get("id") or "")
|
||||||
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
timeout = _bridge_timeout(body.get("timeout"))
|
||||||
fut = pending.get(cid)
|
entry = pending.get(cid)
|
||||||
if fut is None:
|
if entry is None or entry[0] != owner:
|
||||||
fut = asyncio.get_event_loop().create_future()
|
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
|
||||||
pending[cid] = fut
|
fut = entry[1]
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(fut, timeout=timeout)
|
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
||||||
return JSONResponse(result)
|
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)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(cid, None)
|
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
|
except asyncio.CancelledError:
|
||||||
|
return JSONResponse({"ok": False, "error": "cancelled", "status": "cancelled", "completed": False, "id": cid})
|
||||||
|
|
||||||
_AGENT_SYSTEM_PROMPT = (
|
_AGENT_SYSTEM_PROMPT = (
|
||||||
"You are the photo editor agent. You drive the open image editor and "
|
"You are the photo editor agent. You drive the open image editor and "
|
||||||
@@ -1494,11 +1585,14 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
overwritten.
|
overwritten.
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
from src.settings import get_setting
|
||||||
except Exception:
|
disabled = get_setting("disabled_tools", []) or []
|
||||||
body = {}
|
if not isinstance(disabled, (list, tuple, set)):
|
||||||
prompt = str(body.get("prompt") or "").strip()
|
disabled = []
|
||||||
|
if "plan_edit" in disabled:
|
||||||
|
raise HTTPException(403, "Editor tool is disabled")
|
||||||
|
prompt = _prompt(body.get("prompt"))
|
||||||
session = str(body.get("editor_session") or body.get("session") or "default")
|
session = str(body.get("editor_session") or body.get("session") or "default")
|
||||||
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
state = body.get("state") if isinstance(body.get("state"), dict) else {}
|
||||||
screenshot = body.get("screenshot")
|
screenshot = body.get("screenshot")
|
||||||
@@ -1506,10 +1600,10 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||||
agent_model = str(body.get("agent_model") or "").strip()
|
agent_model = str(body.get("agent_model") or "").strip()
|
||||||
tools_offered = _scoped_editor_tools()
|
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||||
scoped = _scoped(session, owner)
|
scoped = _scoped(session, owner)
|
||||||
trace: List[Dict[str, Any]] = [
|
trace: List[Dict[str, Any]] = [
|
||||||
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
|
||||||
]
|
]
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1601,8 +1695,19 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
"results": [],
|
"results": [],
|
||||||
"agent_model": model_used,
|
"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
|
# No image provider: stop before any edit runs and tell the user plainly
|
||||||
# (zero canvas changes; nothing to roll back).
|
# (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:
|
if (kind or _plan_uses_ai_content(steps)) and not provider_available:
|
||||||
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
trace.append({"type": "blocked", "reason": "image_provider_unavailable"})
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1629,24 +1734,30 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
results.append({"op": op, "ok": False, "error": "editor tools unavailable"})
|
||||||
break
|
break
|
||||||
tool_payload = dict(params)
|
tool_payload = dict(params)
|
||||||
tool_payload.setdefault("session", session)
|
# Planner output is untrusted: it cannot redirect this turn to
|
||||||
|
# another editor session or override an explicit model choice.
|
||||||
|
tool_payload["session"] = session
|
||||||
# Route to the user-picked model when one is selected.
|
# Route to the user-picked model when one is selected.
|
||||||
if model_choice:
|
if model_choice:
|
||||||
tool_payload.setdefault("model", model_choice)
|
tool_payload["model"] = model_choice
|
||||||
# Hand the AI step the canvas image the client already captured
|
# Hand the AI step the canvas image the client already captured
|
||||||
# for this request. Without it the tool falls back to a separate
|
# for this request. Without it the tool falls back to a separate
|
||||||
# bridge screenshot, which is not reliably answered right after a
|
# bridge screenshot, which is not reliably answered right after a
|
||||||
# selection step (the masked path was losing the canvas image).
|
# selection step (the masked path was losing the canvas image).
|
||||||
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
if isinstance(screenshot, str) and screenshot.startswith("data:image/"):
|
||||||
tool_payload.setdefault("image", screenshot)
|
tool_payload["image"] = screenshot
|
||||||
|
else:
|
||||||
|
tool_payload.pop("image", None)
|
||||||
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
tool_cls = AiEditImageTool if op == "ai_edit_image" else GenerateImageLayerTool
|
||||||
try:
|
try:
|
||||||
_desc, res = await tool_cls().execute(
|
_desc, res = await tool_cls().execute(
|
||||||
json.dumps(tool_payload),
|
json.dumps(tool_payload),
|
||||||
{"owner": owner, "session_id": scoped},
|
{"owner": owner, "session_id": _scope_label(scoped), "editor_session": session,
|
||||||
|
"incognito": body.get("incognito") is True},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
res = {"error": str(exc), "exit_code": 1}
|
logger.exception("Editor image operation failed")
|
||||||
|
res = {"error": "Editor image operation failed", "exit_code": 1}
|
||||||
res = res if isinstance(res, dict) else {}
|
res = res if isinstance(res, dict) else {}
|
||||||
ok = res.get("exit_code", 0) == 0
|
ok = res.get("exit_code", 0) == 0
|
||||||
step_result = {
|
step_result = {
|
||||||
@@ -1664,15 +1775,17 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
cid = uuid.uuid4().hex
|
cid = uuid.uuid4().hex
|
||||||
pending[cid] = asyncio.get_event_loop().create_future()
|
fut = asyncio.get_event_loop().create_future()
|
||||||
|
pending[cid] = (owner, fut)
|
||||||
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
_broadcast(scoped, {"id": cid, "op": op, "params": params})
|
||||||
try:
|
try:
|
||||||
res = await asyncio.wait_for(pending[cid], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
res = await asyncio.wait_for(pending[cid][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pending.pop(cid, None)
|
|
||||||
results.append({"op": op, "ok": False, "error": "timeout"})
|
results.append({"op": op, "ok": False, "error": "timeout"})
|
||||||
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
trace.append({"type": "step", "op": op, "ok": False, "error": "timeout"})
|
||||||
break
|
break
|
||||||
|
finally:
|
||||||
|
_expire_command(scoped, cid, fut)
|
||||||
results.append(res)
|
results.append(res)
|
||||||
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
trace.append({"type": "step", "op": op, "ok": res.get("ok"), "result": res.get("result")})
|
||||||
if res.get("ok") is False:
|
if res.get("ok") is False:
|
||||||
@@ -1683,13 +1796,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
if steps and not any(r.get("ok") is False for r in results):
|
if steps and not any(r.get("ok") is False for r in results):
|
||||||
try:
|
try:
|
||||||
_info_id = uuid.uuid4().hex
|
_info_id = uuid.uuid4().hex
|
||||||
pending[_info_id] = asyncio.get_event_loop().create_future()
|
_info_fut = asyncio.get_event_loop().create_future()
|
||||||
|
pending[_info_id] = (owner, _info_fut)
|
||||||
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
_broadcast(scoped, {"id": _info_id, "op": "document.info", "params": {}})
|
||||||
_info = await asyncio.wait_for(pending[_info_id], timeout=60)
|
_info = await asyncio.wait_for(pending[_info_id][1], timeout=60)
|
||||||
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
if isinstance(_info, dict) and isinstance(_info.get("result"), dict):
|
||||||
final_state = _info["result"]
|
final_state = _info["result"]
|
||||||
except Exception:
|
except Exception:
|
||||||
final_state = {}
|
final_state = {}
|
||||||
|
finally:
|
||||||
|
_expire_command(scoped, _info_id, _info_fut)
|
||||||
planned_reply = reply
|
planned_reply = reply
|
||||||
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
reply = _verified_reply(prompt, steps, results, final_state, planned_reply)
|
||||||
if special_note and special_note.strip() not in reply:
|
if special_note and special_note.strip() not in reply:
|
||||||
@@ -1726,6 +1842,7 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
``edit`` (editing/inpainting models) or ``generate`` (text-to-image).
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
|
is_admin = _is_admin(request)
|
||||||
models: List[Dict[str, Any]] = []
|
models: List[Dict[str, Any]] = []
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
try:
|
try:
|
||||||
@@ -1748,7 +1865,10 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
# recognisably image-capable — the same test the request
|
# recognisably image-capable — the same test the request
|
||||||
# router uses.
|
# router uses.
|
||||||
image_endpoint = getattr(ep, "model_type", None) == "image"
|
image_endpoint = getattr(ep, "model_type", None) == "image"
|
||||||
provider = getattr(ep, "name", None) or getattr(ep, "base_url", "") or "image"
|
provider = getattr(ep, "name", None)
|
||||||
|
if not provider:
|
||||||
|
provider = getattr(ep, "base_url", "") if is_admin else "image"
|
||||||
|
provider = provider or "image"
|
||||||
for name in _endpoint_enabled_models(ep):
|
for name in _endpoint_enabled_models(ep):
|
||||||
if not image_endpoint and not looks_like_image_generation_model(name):
|
if not image_endpoint and not looks_like_image_generation_model(name):
|
||||||
continue
|
continue
|
||||||
@@ -1756,14 +1876,16 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
models.append({
|
entry = {
|
||||||
"id": name,
|
"id": name,
|
||||||
"label": name,
|
"label": name,
|
||||||
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
"kind": "edit" if _EDIT_MODEL_RE.search(name) else "generate",
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"endpoint_id": getattr(ep, "id", None),
|
"endpoint_id": getattr(ep, "id", None),
|
||||||
"base_url": getattr(ep, "base_url", "") or "",
|
}
|
||||||
})
|
if is_admin:
|
||||||
|
entry["base_url"] = getattr(ep, "base_url", "") or ""
|
||||||
|
models.append(entry)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1781,35 +1903,45 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
falls back to).
|
falls back to).
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
|
is_admin = _is_admin(request)
|
||||||
try:
|
try:
|
||||||
models = await editor_agent_model.list_editor_agent_models(owner)
|
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:
|
except Exception:
|
||||||
logger.warning("editor agent-models lookup failed", exc_info=True)
|
logger.warning("editor agent-models lookup failed", exc_info=True)
|
||||||
models = []
|
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({
|
return JSONResponse({
|
||||||
"models": models,
|
"models": models,
|
||||||
"probe": editor_agent_model.probe_status(),
|
"probe": probe_info,
|
||||||
})
|
})
|
||||||
|
|
||||||
@router.post("/api/editor/self-check")
|
@router.post("/api/editor/self-check")
|
||||||
async def editor_self_check(request: Request) -> JSONResponse:
|
async def editor_self_check(request: Request) -> JSONResponse:
|
||||||
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
"""Same-origin VL self-check: compare BEFORE/AFTER against the intent.
|
||||||
|
|
||||||
The local VL server (seneca :8097) sends no CORS headers, so the browser
|
A configured vision server may not provide browser CORS headers. This route
|
||||||
cannot call it directly and the self-check silently "passed". This route
|
|
||||||
proxies the two images + intent to the agent VLM and returns
|
proxies the two images + intent to the agent VLM and returns
|
||||||
``{done, issues, retry_hint, text}`` (``done=True`` with
|
``{done, issues, retry_hint, text}`` (``done=True`` with
|
||||||
``reason="unavailable"`` on any failure so the client never loops).
|
``reason="unavailable"`` on any failure so the client never loops).
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
try:
|
body = await _json_object(request)
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
result = await editor_agent_model.self_check(
|
result = await editor_agent_model.self_check(
|
||||||
body.get("before"),
|
body.get("before"),
|
||||||
body.get("after"),
|
body.get("after"),
|
||||||
str(body.get("prompt") or body.get("intent") or ""),
|
_prompt(body.get("prompt") or body.get("intent")),
|
||||||
model=str(body.get("model") or "").strip() or None,
|
model=str(body.get("model") or "").strip() or None,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1827,18 +1827,22 @@ def setup_email_routes():
|
|||||||
# a cached message body.
|
# a cached message body.
|
||||||
return (account_id or "", folder, str(uid), owner)
|
return (account_id or "", folder, str(uid), owner)
|
||||||
|
|
||||||
def _list_cache_get(key):
|
|
||||||
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 = {}
|
_LIST_STALE = {}
|
||||||
|
_LIST_LOCK = _threading.RLock()
|
||||||
|
_LIST_EPOCH = [0]
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
def _list_stale_get(key):
|
def _list_stale_get(key):
|
||||||
return _LIST_STALE.get(key)
|
with _LIST_LOCK:
|
||||||
|
return _LIST_STALE.get(key)
|
||||||
|
|
||||||
def _list_stale_put(key, value):
|
def _list_stale_put(key, value):
|
||||||
_LIST_STALE[key] = value
|
_LIST_STALE[key] = value
|
||||||
@@ -1849,13 +1853,13 @@ def setup_email_routes():
|
|||||||
_LIST_REFRESHING = set()
|
_LIST_REFRESHING = set()
|
||||||
|
|
||||||
def _list_cache_put(key, value):
|
def _list_cache_put(key, value):
|
||||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
with _LIST_LOCK:
|
||||||
# Also snapshot the last-good response for stale-while-revalidate.
|
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||||
_list_stale_put(key, value)
|
# Also snapshot the last-good response for stale-while-revalidate.
|
||||||
# Cap size
|
_list_stale_put(key, value)
|
||||||
if len(_LIST_CACHE) > 64:
|
if len(_LIST_CACHE) > 64:
|
||||||
for k in list(_LIST_CACHE.keys())[:-32]:
|
for k in list(_LIST_CACHE.keys())[:-32]:
|
||||||
_LIST_CACHE.pop(k, None)
|
_LIST_CACHE.pop(k, None)
|
||||||
|
|
||||||
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
|
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.
|
"""Refresh a list snapshot off the event loop, at most once per key.
|
||||||
@@ -1863,15 +1867,20 @@ def setup_email_routes():
|
|||||||
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
|
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.
|
the TTL cache and the stale snapshot so the next list view is warm.
|
||||||
"""
|
"""
|
||||||
if cache_key in _LIST_REFRESHING:
|
with _LIST_LOCK:
|
||||||
return
|
if cache_key in _LIST_REFRESHING:
|
||||||
_LIST_REFRESHING.add(cache_key)
|
return
|
||||||
|
_LIST_REFRESHING.add(cache_key)
|
||||||
|
epoch = _LIST_EPOCH[0]
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
refreshed = _list_emails_sync(*sync_args)
|
refreshed = _list_emails_sync(*sync_args)
|
||||||
if refreshed and not refreshed.get("error"):
|
if refreshed and not refreshed.get("error"):
|
||||||
_list_cache_put(cache_key, refreshed)
|
with _LIST_LOCK:
|
||||||
|
if epoch != _LIST_EPOCH[0]:
|
||||||
|
return
|
||||||
|
_list_cache_put(cache_key, refreshed)
|
||||||
try:
|
try:
|
||||||
_schedule_recent_email_warm(
|
_schedule_recent_email_warm(
|
||||||
refreshed.get("emails") or [], folder, account_id, owner)
|
refreshed.get("emails") or [], folder, account_id, owner)
|
||||||
@@ -1880,7 +1889,8 @@ def setup_email_routes():
|
|||||||
except Exception as exc: # pragma: no cover - background best effort
|
except Exception as exc: # pragma: no cover - background best effort
|
||||||
logger.debug("email list background refresh failed: %s", exc)
|
logger.debug("email list background refresh failed: %s", exc)
|
||||||
finally:
|
finally:
|
||||||
_LIST_REFRESHING.discard(cache_key)
|
with _LIST_LOCK:
|
||||||
|
_LIST_REFRESHING.discard(cache_key)
|
||||||
|
|
||||||
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
|
||||||
|
|
||||||
@@ -1917,37 +1927,64 @@ def setup_email_routes():
|
|||||||
hook(account_id, folder)
|
hook(account_id, folder)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if account_id is None and folder is None:
|
with _LIST_LOCK:
|
||||||
_LIST_CACHE.clear()
|
_LIST_EPOCH[0] += 1
|
||||||
return
|
if account_id is None and folder is None:
|
||||||
for k in list(_LIST_CACHE.keys()):
|
_LIST_CACHE.clear()
|
||||||
k_acct = k[0] if len(k) > 0 else ""
|
_LIST_STALE.clear()
|
||||||
k_folder = k[1] if len(k) > 1 else ""
|
return
|
||||||
if (account_id is None or k_acct == (account_id or "")) and \
|
for cache in (_LIST_CACHE, _LIST_STALE):
|
||||||
(folder is None or k_folder == folder):
|
for k in list(cache.keys()):
|
||||||
_LIST_CACHE.pop(k, None)
|
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 _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
|
||||||
uid_s = str(uid)
|
uid_s = str(uid)
|
||||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
with _LIST_LOCK:
|
||||||
if key[0] != (account_id or "") or key[1] != folder:
|
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||||
continue
|
if key[0] != (account_id or "") or key[1] != folder:
|
||||||
emails = list((value or {}).get("emails") or [])
|
continue
|
||||||
changed = False
|
emails = list((value or {}).get("emails") or [])
|
||||||
if seen and key[2] == "unread":
|
changed = False
|
||||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
if seen and key[2] == "unread":
|
||||||
if len(kept) != len(emails):
|
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||||
value = dict(value)
|
if len(kept) != len(emails):
|
||||||
value["emails"] = kept
|
value = dict(value)
|
||||||
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
value["emails"] = kept
|
||||||
changed = True
|
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
|
||||||
else:
|
|
||||||
for e in emails:
|
|
||||||
if str((e or {}).get("uid") or "") == uid_s:
|
|
||||||
e["is_read"] = bool(seen)
|
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
else:
|
||||||
_LIST_CACHE[key] = (expires_at, value)
|
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
|
||||||
|
|
||||||
def _read_cache_get(key):
|
def _read_cache_get(key):
|
||||||
v = _READ_CACHE.get(key)
|
v = _READ_CACHE.get(key)
|
||||||
@@ -2915,7 +2952,7 @@ def setup_email_routes():
|
|||||||
# with a `stale` marker and refresh in the background so the next view
|
# 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
|
# 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).
|
# snapshot and no indexed rows (e.g. a brand-new account/folder).
|
||||||
if not from_addr and not has_attachments and not (date_from or date_to):
|
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
|
||||||
snapshot = _list_stale_get(ck)
|
snapshot = _list_stale_get(ck)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -779,14 +779,17 @@ async def _forward_seneca_image_op(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
raise HTTPException(r.status_code if r.status_code in (400, 404, 422) else 502, detail)
|
||||||
result = r.json()
|
try:
|
||||||
|
result = r.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(502, "Image endpoint returned invalid JSON") from exc
|
||||||
raw_b64: Optional[str] = None
|
raw_b64: Optional[str] = None
|
||||||
used_model = ""
|
used_model = ""
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
used_model = str(result.get("model") or "")
|
used_model = str(result.get("model") or "")
|
||||||
data = result.get("data")
|
data = result.get("data")
|
||||||
if isinstance(data, list) and data:
|
if isinstance(data, list) and data:
|
||||||
item = data[0] or {}
|
item = data[0] if isinstance(data[0], dict) else {}
|
||||||
raw_b64 = item.get("b64_json") or None
|
raw_b64 = item.get("b64_json") or None
|
||||||
if not raw_b64 and item.get("url"):
|
if not raw_b64 and item.get("url"):
|
||||||
raw_b64 = await _fetch_result_image_b64(item["url"])
|
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||||
@@ -1842,8 +1845,8 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
try:
|
try:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s",
|
"inpaint_proxy provider edit -> model=%r size=%r base=%s",
|
||||||
body.get("prompt", ""), oa_model, size, base,
|
oa_model, size, base,
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient(timeout=300) as client:
|
async with httpx.AsyncClient(timeout=300) as client:
|
||||||
if is_openrouter:
|
if is_openrouter:
|
||||||
@@ -1978,9 +1981,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
finally:
|
finally:
|
||||||
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
_restore_ms[0] += (time.perf_counter() - _rs) * 1000.0
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r "
|
"inpaint_proxy self-hosted /images/edits -> model=%r "
|
||||||
"size=%r steps=%r endpoint=%s",
|
"size=%r steps=%r endpoint=%s",
|
||||||
data.get("prompt"), data.get("model"), data.get("size"),
|
data.get("model"), data.get("size"),
|
||||||
data.get("steps"), base,
|
data.get("steps"), base,
|
||||||
)
|
)
|
||||||
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
||||||
|
|||||||
@@ -78,7 +78,16 @@ def _parse(content: Any) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||||
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
|
||||||
@@ -107,17 +116,26 @@ def _data_url_bytes(data_url: str) -> bytes:
|
|||||||
return base64.b64decode(b64)
|
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]:
|
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}."""
|
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
|
||||||
from core.database import GalleryImage, SessionLocal
|
from core.database import GalleryImage, SessionLocal
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
image_path = None
|
||||||
try:
|
try:
|
||||||
root = Path(GENERATED_IMAGES_DIR).resolve()
|
root = Path(GENERATED_IMAGES_DIR).resolve()
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
(root / filename).write_bytes(image_bytes)
|
image_path = root / filename
|
||||||
|
image_path.write_bytes(image_bytes)
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
width = height = 0
|
width = height = 0
|
||||||
try:
|
try:
|
||||||
@@ -163,6 +181,11 @@ 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}
|
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
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
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -198,7 +221,7 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
|
||||||
return data.get("ok") is not False
|
return bool(data.get("ok") is True and data.get("completed", True) is not False)
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
logger.warning("editor layer placement failed: %s", exc)
|
logger.warning("editor layer placement failed: %s", exc)
|
||||||
return False
|
return False
|
||||||
@@ -367,28 +390,46 @@ class RunEditorCommandTool:
|
|||||||
args = _parse(content)
|
args = _parse(content)
|
||||||
op = str(args.get("op") or "").strip()
|
op = str(args.get("op") or "").strip()
|
||||||
if not op:
|
if not op:
|
||||||
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
|
||||||
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||||
|
wait = bool(args.get("wait", True))
|
||||||
payload = {
|
payload = {
|
||||||
"session": _session(args, ctx),
|
"session": _session(args, ctx),
|
||||||
"op": op,
|
"op": op,
|
||||||
"params": params,
|
"params": params,
|
||||||
"wait": bool(args.get("wait", True)),
|
"wait": wait,
|
||||||
"timeout": float(args.get("timeout") or 60),
|
"timeout": float(args.get("timeout") or 60),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
|
||||||
ok = data.get("ok")
|
|
||||||
|
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")
|
||||||
result = data.get("result")
|
result = data.get("result")
|
||||||
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||||
return f"run_editor_command: {op}", {
|
return f"run_editor_command: {op}", {
|
||||||
"output": summary[:4000],
|
"output": summary[:4000],
|
||||||
"result": result,
|
"result": result,
|
||||||
"screenshot": data.get("screenshot"),
|
"screenshot": data.get("screenshot"),
|
||||||
|
"status": status,
|
||||||
|
"completed": ok,
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
"exit_code": 0 if ok is not False else 1,
|
"exit_code": 0 if ok else 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -442,10 +483,14 @@ class AiEditImageTool:
|
|||||||
image = _as_data_url(args.get("image"))
|
image = _as_data_url(args.get("image"))
|
||||||
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
|
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.
|
# 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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=60) as client:
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
|
||||||
resp.raise_for_status()
|
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())
|
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
|
||||||
except Exception:
|
except Exception:
|
||||||
image = None
|
image = None
|
||||||
@@ -550,10 +595,13 @@ class AiEditImageTool:
|
|||||||
"model": data.get("model") or model_choice or "",
|
"model": data.get("model") or model_choice or "",
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
try:
|
if _incognito(ctx):
|
||||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
info["image_url"] = data_url
|
||||||
except Exception as exc:
|
else:
|
||||||
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()
|
_t["persist"] = time.perf_counter()
|
||||||
if bool(args.get("place", True)):
|
if bool(args.get("place", True)):
|
||||||
placed = await _place_layer(
|
placed = await _place_layer(
|
||||||
@@ -626,10 +674,13 @@ class GenerateImageLayerTool:
|
|||||||
"model": data.get("image_model") or str(args.get("model") or ""),
|
"model": data.get("image_model") or str(args.get("model") or ""),
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
try:
|
if _incognito(ctx):
|
||||||
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
info["image_url"] = data_url
|
||||||
except Exception as exc:
|
else:
|
||||||
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)):
|
if bool(args.get("place", True)):
|
||||||
placed = await _place_layer(
|
placed = await _place_layer(
|
||||||
data_url,
|
data_url,
|
||||||
|
|||||||
@@ -341,12 +341,6 @@ async def self_check(
|
|||||||
|
|
||||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
||||||
cands: 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():
|
for base in local_bases():
|
||||||
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
cands.append({"base": base, "model": local_model(), "headers": None, "source": "local"})
|
||||||
try:
|
try:
|
||||||
@@ -362,6 +356,17 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
|
|||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if override:
|
||||||
|
base, model = _split_override(override)
|
||||||
|
# A request may choose a configured endpoint, never introduce a URL.
|
||||||
|
# Prefer an exact model match (including hosted credentials) over the
|
||||||
|
# first local endpoint when a bare model id is supplied.
|
||||||
|
matching = [c for c in cands if not base or str(c["base"]).rstrip("/") == base.rstrip("/")]
|
||||||
|
selected = next((c for c in matching if c["model"] == model), None)
|
||||||
|
if selected is None and model and matching and matching[0]["source"] == "local":
|
||||||
|
selected = {**matching[0], "model": model}
|
||||||
|
if selected is not None:
|
||||||
|
cands.insert(0, {**selected, "source": "override"})
|
||||||
return cands
|
return cands
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_RECOLOR_TRAILING_ADJ = re.compile(
|
_RECOLOR_TRAILING_ADJECTIVES = frozenset({
|
||||||
r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|"
|
"bright", "brighter", "vivid", "vibrant", "dark", "darker", "light",
|
||||||
r"pale|more|less|very|really|slightly|totally|completely)\b\s*$",
|
"lighter", "deep", "pale", "more", "less", "very", "really",
|
||||||
re.I,
|
"slightly", "totally", "completely",
|
||||||
)
|
})
|
||||||
|
|
||||||
|
|
||||||
def _hex_rgb01(hex_color: str):
|
def _hex_rgb01(hex_color: str):
|
||||||
@@ -557,9 +557,9 @@ def _recolor_object_phrase(prompt: str, target_word: str) -> str:
|
|||||||
words.pop(0)
|
words.pop(0)
|
||||||
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
|
if len(words) > 1 and words[-1].casefold() in {"to", "into"}:
|
||||||
words.pop()
|
words.pop()
|
||||||
|
while len(words) > 1 and words[-1].lower() in _RECOLOR_TRAILING_ADJECTIVES:
|
||||||
|
words.pop()
|
||||||
phrase = " ".join(words)
|
phrase = " ".join(words)
|
||||||
while phrase and _RECOLOR_TRAILING_ADJ.search(phrase):
|
|
||||||
phrase = _RECOLOR_TRAILING_ADJ.sub("", phrase).strip()
|
|
||||||
return phrase or "the subject"
|
return phrase or "the subject"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,12 +119,6 @@ def execute_fixture_email(tool: str, args: dict[str, Any], owner: str | None = N
|
|||||||
|
|
||||||
bare = tool.removeprefix("mcp__email__")
|
bare = tool.removeprefix("mcp__email__")
|
||||||
if bare == "list_email_accounts":
|
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 {
|
return {
|
||||||
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
"accounts": [{"id": "fixture", "name": "Fixture mailbox", "default": True}],
|
||||||
"output": "Fixture mailbox (default)",
|
"output": "Fixture mailbox (default)",
|
||||||
|
|||||||
+2
-3
@@ -47,9 +47,8 @@ DEFAULT_SETTINGS = {
|
|||||||
"image_gen_enabled": False,
|
"image_gen_enabled": False,
|
||||||
"image_model": "",
|
"image_model": "",
|
||||||
"image_quality": "medium",
|
"image_quality": "medium",
|
||||||
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
|
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||||
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
|
# fallback without probing a developer-specific host.
|
||||||
# back to the hosted chat model. See src/editor_agent_model.py.
|
|
||||||
"editor_agent_base_url": "",
|
"editor_agent_base_url": "",
|
||||||
"editor_agent_model": "",
|
"editor_agent_model": "",
|
||||||
"editor_caption_base_url": "",
|
"editor_caption_base_url": "",
|
||||||
|
|||||||
@@ -227,8 +227,16 @@ _register(
|
|||||||
ToolEffect.READ_PRIVATE,
|
ToolEffect.READ_PRIVATE,
|
||||||
)
|
)
|
||||||
_register(
|
_register(
|
||||||
{"plan_edit", "run_editor_command"},
|
{"plan_edit"},
|
||||||
|
ToolEffect.NETWORK_EGRESS,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
|
)
|
||||||
|
_register(
|
||||||
|
{"run_editor_command"},
|
||||||
|
ToolEffect.READ_PRIVATE,
|
||||||
|
ToolEffect.WRITE_PRIVATE,
|
||||||
ToolEffect.UI_SIDE_EFFECT,
|
ToolEffect.UI_SIDE_EFFECT,
|
||||||
|
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||||
)
|
)
|
||||||
_register(
|
_register(
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -1545,7 +1545,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"properties": {
|
"properties": {
|
||||||
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
||||||
"params": {"type": "object", "description": "Parameters for the op"},
|
"params": {"type": "object", "description": "Parameters for the op"},
|
||||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
|
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"},
|
||||||
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
||||||
},
|
},
|
||||||
"required": ["op"]
|
"required": ["op"]
|
||||||
@@ -1560,7 +1560,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
|
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)."},
|
"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)"},
|
"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"},
|
"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"}
|
"session": {"type": "string", "description": "Editor session id when no turn-bound session is available; the bound session takes precedence"}
|
||||||
},
|
},
|
||||||
"required": ["op"]
|
"required": ["op"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2997,18 +2997,6 @@ def _natural_safe_inventory_operation(message: str, maximum: int | None = None)
|
|||||||
return RequiredReadOperation(tool, {"action": action} if action else {}, maximum)
|
return RequiredReadOperation(tool, {"action": action} if action else {}, maximum)
|
||||||
|
|
||||||
|
|
||||||
def note_read_intent(user_text: str) -> tuple[str, bool]:
|
|
||||||
"""Return a quoted mention query and whether the note body is needed."""
|
|
||||||
text = str(user_text or "")
|
|
||||||
mention = re.search(r"\bmentions?\s+(['\"])([^'\"]{1,120})\1", text, re.I)
|
|
||||||
query = mention.group(2).strip() if mention else ""
|
|
||||||
needs_body = bool(
|
|
||||||
re.search(r"\b(?:summari[sz]e|summary|main\s+topics)\b", text, re.I)
|
|
||||||
or re.search(r"\b(?:show|open|read|view)\b.{0,80}\b(?:first|newest|most\s+recent)\s+note\b", text, re.I)
|
|
||||||
)
|
|
||||||
return query, needs_body
|
|
||||||
|
|
||||||
|
|
||||||
def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None:
|
def required_read_operation_for_request(message: str, history: Iterable = ()) -> RequiredReadOperation | None:
|
||||||
"""Resolve complete list/read requests, or repeat an exact prior read.
|
"""Resolve complete list/read requests, or repeat an exact prior read.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ from routes import editor_agent_routes as eaar
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_planner():
|
def _reset_planner(monkeypatch):
|
||||||
|
# Minimal router fixtures have no authentication middleware.
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
eaar.set_editor_planner(None)
|
eaar.set_editor_planner(None)
|
||||||
yield
|
yield
|
||||||
eaar.set_editor_planner(None)
|
eaar.set_editor_planner(None)
|
||||||
@@ -161,6 +163,22 @@ def test_bridge_command_accepts_selection_export_mask(client):
|
|||||||
assert ok["queued"], ok
|
assert ok["queued"], ok
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["/api/editor/plan", "/api/editor/agent", "/api/editor/bridge/command",
|
||||||
|
"/api/editor/bridge/result", "/api/editor/bridge/await", "/api/editor/self-check"])
|
||||||
|
def test_editor_routes_reject_non_object_json(client, path):
|
||||||
|
assert client.post(path, json=[]).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["/api/editor/plan", "/api/editor/agent"])
|
||||||
|
def test_editor_routes_bound_prompt_before_planning(client, path):
|
||||||
|
assert client.post(path, json={"prompt": "x" * 4097}).status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["abc", "NaN", "Infinity", -1, 901])
|
||||||
|
def test_bridge_rejects_invalid_timeout(client, value):
|
||||||
|
assert client.post("/api/editor/bridge/command", json={"op": "screenshot", "timeout": value}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_set_editor_planner_is_used_by_setup_when_set():
|
def test_set_editor_planner_is_used_by_setup_when_set():
|
||||||
sentinel = lambda messages, owner: None # noqa: E731
|
sentinel = lambda messages, owner: None # noqa: E731
|
||||||
eaar.set_editor_planner(sentinel)
|
eaar.set_editor_planner(sentinel)
|
||||||
@@ -838,6 +856,225 @@ def test_bridge_roundtrip_subscribe_command_result():
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_rejects_result_from_other_owner(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
monkeypatch.setattr(eaar, "_user", lambda request: request.headers.get("x-test-owner"))
|
||||||
|
monkeypatch.setattr(eaar.editor_agent_model, "startup_probe", lambda: None)
|
||||||
|
monkeypatch.setattr(eaar.uuid, "uuid4", lambda: SimpleNamespace(hex="known-command-id"))
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
pending = asyncio.create_task(client.post(
|
||||||
|
"/api/editor/bridge/command", headers={"x-test-owner": "alice"},
|
||||||
|
json={"session": "s", "op": "screenshot", "wait": True, "timeout": 2},
|
||||||
|
))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
payload = {"id": "known-command-id", "ok": True, "result": {"value": "legitimate"}}
|
||||||
|
denied = await client.post("/api/editor/bridge/result", headers={"x-test-owner": "bob"}, json=payload)
|
||||||
|
assert denied.status_code == 404
|
||||||
|
denied_await = await client.post("/api/editor/bridge/await", headers={"x-test-owner": "bob"}, json=payload)
|
||||||
|
assert denied_await.status_code == 404
|
||||||
|
accepted = await client.post("/api/editor/bridge/result", headers={"x-test-owner": "alice"}, json=payload)
|
||||||
|
assert accepted.status_code == 200
|
||||||
|
assert (await pending).json()["result"] == {"value": "legitimate"}
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_session_keys_do_not_collide_on_colons(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
monkeypatch.setattr(eaar, "_user", lambda request: request.headers.get("x-owner"))
|
||||||
|
monkeypatch.setattr(eaar.editor_agent_model, "startup_probe", lambda: None)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
received = []
|
||||||
|
stop = asyncio.Event()
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
await stop.wait()
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
async def send(message):
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
received.append(message.get("body", b""))
|
||||||
|
|
||||||
|
subscriber = asyncio.create_task(app({
|
||||||
|
"type": "http", "method": "GET", "path": "/api/editor/bridge/events",
|
||||||
|
"query_string": b"session=b%3Ac", "headers": [(b"x-owner", b"a")],
|
||||||
|
}, receive, send))
|
||||||
|
for _ in range(100):
|
||||||
|
if any(b"connected" in item for item in received):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
await client.post("/api/editor/bridge/command", headers={"x-owner": "a:b"},
|
||||||
|
json={"session": "c", "op": "screenshot"})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert not any(b"data:" in item for item in received)
|
||||||
|
await client.post("/api/editor/bridge/command", headers={"x-owner": "a"},
|
||||||
|
json={"session": "b:c", "op": "document.info"})
|
||||||
|
for _ in range(100):
|
||||||
|
if any(b"document.info" in item for item in received):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
assert any(b"document.info" in item for item in received)
|
||||||
|
stop.set()
|
||||||
|
await asyncio.wait_for(subscriber, 2)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_no_wait_result_can_be_awaited_after_it_arrives(client):
|
||||||
|
queued = client.post("/api/editor/bridge/command", json={
|
||||||
|
"session": "s", "op": "document.info", "wait": False,
|
||||||
|
}).json()
|
||||||
|
command_id = queued["queued"][0]
|
||||||
|
client.post("/api/editor/bridge/result", json={"id": command_id, "ok": True, "result": {"width": 10}})
|
||||||
|
result = client.post("/api/editor/bridge/await", json={"id": command_id, "timeout": 0.01}).json()
|
||||||
|
assert result.get("result") == {"width": 10}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_multiple_results_remain_independent(client):
|
||||||
|
queued = client.post("/api/editor/bridge/command", json={
|
||||||
|
"session": "s", "steps": [{"op": "document.info"}, {"op": "screenshot"}], "wait": True,
|
||||||
|
}).json()["queued"]
|
||||||
|
assert len(queued) == 2
|
||||||
|
for command_id, value in zip(reversed(queued), (2, 1)):
|
||||||
|
client.post("/api/editor/bridge/result", json={"id": command_id, "ok": True, "result": {"value": value}})
|
||||||
|
for command_id, value in zip(queued, (1, 2)):
|
||||||
|
result = client.post("/api/editor/bridge/await", json={"id": command_id, "timeout": 0.1}).json()
|
||||||
|
assert result["result"] == {"value": value}
|
||||||
|
|
||||||
|
|
||||||
|
def test_timed_out_bridge_command_is_not_replayed():
|
||||||
|
import asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
app = _bridge_app()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.post("/api/editor/bridge/command", json={
|
||||||
|
"session": "late", "op": "text.add", "wait": True, "timeout": 0.01,
|
||||||
|
})
|
||||||
|
assert response.json()["error"] == "timeout"
|
||||||
|
received = []
|
||||||
|
stop = asyncio.Event()
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
await stop.wait()
|
||||||
|
return {"type": "http.disconnect"}
|
||||||
|
|
||||||
|
async def send(message):
|
||||||
|
if message["type"] == "http.response.body":
|
||||||
|
received.append(message.get("body", b""))
|
||||||
|
|
||||||
|
subscriber = asyncio.create_task(app({"type": "http", "method": "GET",
|
||||||
|
"path": "/api/editor/bridge/events", "query_string": b"session=late", "headers": []}, receive, send))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await client.post("/api/editor/bridge/command", json={"session": "late", "op": "document.info"})
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert any(b"document.info" in item for item in received)
|
||||||
|
assert not any(b"text.add" in item for item in received)
|
||||||
|
stop.set()
|
||||||
|
await asyncio.wait_for(subscriber, 2)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_bridge_command_is_not_pending(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
monkeypatch.setattr(eaar.uuid, "uuid4", lambda: SimpleNamespace(hex="cancelled-id"))
|
||||||
|
app = _bridge_app()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
task = asyncio.create_task(client.post("/api/editor/bridge/command", json={
|
||||||
|
"session": "late", "op": "text.add", "wait": True, "timeout": 2,
|
||||||
|
}))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
result = await client.post("/api/editor/bridge/result", json={"id": "cancelled-id", "ok": True})
|
||||||
|
assert result.json() == {"ok": True}
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_agent_denies_image_generation_without_privilege(monkeypatch):
|
||||||
|
class Auth:
|
||||||
|
is_configured = True
|
||||||
|
|
||||||
|
def get_privileges(self, user):
|
||||||
|
return {"can_generate_images": False}
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.auth_manager = Auth()
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def identity(request, call_next):
|
||||||
|
request.state.current_user = "alice"
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: {
|
||||||
|
"reply": "generate", "steps": [{"op": "generate_image", "params": {"prompt": "a tree"}}]
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post("/api/editor/agent", json={"prompt": "generate an image of a tree"})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_owner_routes_reject_delegated_token():
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def identity(request, call_next):
|
||||||
|
request.state.current_user = "api"
|
||||||
|
request.state.api_token = True
|
||||||
|
request.state.api_token_owner = "alice"
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.include_router(eaar.setup_editor_agent_routes())
|
||||||
|
with TestClient(app) as client:
|
||||||
|
for path in ("/api/editor/plan", "/api/editor/bridge/command", "/api/editor/agent"):
|
||||||
|
assert client.post(path, json={"prompt": "hello"}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_tool_session_uses_bound_turn_not_model_arguments():
|
||||||
|
from src.agent_tools.editor_tools import _session
|
||||||
|
args = {"session": "other-editor", "editor_session": "also-other"}
|
||||||
|
# Explicit editor_session bound in ctx takes precedence over args
|
||||||
|
assert _session(args, {"editor_session": "authorized-editor"}) == "authorized-editor"
|
||||||
|
# When no explicit editor_session is bound, tool/caller argument is used (chat turns have chat session_id)
|
||||||
|
assert _session(args, {"session_id": "chat-uuid"}) == "other-editor"
|
||||||
|
assert _session({}, {}) == "default"
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_agent_honors_disabled_image_tool_before_dispatch(client, monkeypatch):
|
||||||
|
import src.settings as settings
|
||||||
|
from src.agent_tools import editor_tools
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "get_setting", lambda name, default=None: ["ai_edit_image"] if name == "disabled_tools" else default)
|
||||||
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
|
||||||
|
monkeypatch.setattr(editor_tools.AiEditImageTool, "execute", lambda *args: pytest.fail("disabled tool ran"))
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: {"steps": [{"op": "ai_edit_image", "params": {"op": "denoise"}}]})
|
||||||
|
assert client.post("/api/editor/agent", json={"prompt": "do this"}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_bridge_broadcasts_to_every_subscriber():
|
def test_bridge_broadcasts_to_every_subscriber():
|
||||||
"""URGENT regression: with two live subscribers on the same session (EventSource
|
"""URGENT regression: with two live subscribers on the same session (EventSource
|
||||||
reconnects overlap), a command must reach BOTH, never be eaten by one."""
|
reconnects overlap), a command must reach BOTH, never be eaten by one."""
|
||||||
@@ -943,6 +1180,129 @@ def test_ai_edit_image_tool_forwards_and_reports_model(monkeypatch):
|
|||||||
assert res.get("model") == "qwen-image-edit-2511", res
|
assert res.get("model") == "qwen-image-edit-2511", res
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_editor_tool_incognito_does_not_persist(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
import src.agent_tools.editor_tools as et
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (32, 32), (10, 20, 30)).save(buf, "PNG")
|
||||||
|
image = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||||
|
saved = []
|
||||||
|
|
||||||
|
async def fake_post(*args, **kwargs):
|
||||||
|
return {"image": image}
|
||||||
|
|
||||||
|
monkeypatch.setattr(et, "_post", fake_post)
|
||||||
|
monkeypatch.setattr(et, "_persist_png", lambda *args: saved.append(args) or {})
|
||||||
|
_, result = asyncio.run(et.AiEditImageTool().execute(
|
||||||
|
{"op": "denoise", "image": image, "place": False},
|
||||||
|
{"owner": "u", "client_runtime_context": {"web_ui_state": {"incognito": True}}},
|
||||||
|
))
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert not saved
|
||||||
|
assert result["image_url"] == image
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_editor_tool_rejects_unrelated_internal_url(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
import src.agent_tools.editor_tools as et
|
||||||
|
|
||||||
|
class NeverFetch:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pytest.fail("unrelated internal route must not be fetched")
|
||||||
|
|
||||||
|
monkeypatch.setattr(et.httpx, "AsyncClient", NeverFetch)
|
||||||
|
_, result = asyncio.run(et.AiEditImageTool().execute(
|
||||||
|
{"op": "denoise", "image": "/api/email/messages?folder=INBOX", "place": False},
|
||||||
|
{"owner": "alice", "session_id": "authorized"},
|
||||||
|
))
|
||||||
|
assert result["exit_code"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_editor_tool_accepts_owned_generated_image_reference(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
import src.agent_tools.editor_tools as et
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (16, 16), (10, 20, 30)).save(buf, "PNG")
|
||||||
|
image = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def __init__(self, *args, **kwargs): pass
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *args): pass
|
||||||
|
async def get(self, url, **kwargs):
|
||||||
|
seen.append(url)
|
||||||
|
return __import__('httpx').Response(200, content=buf.getvalue(),
|
||||||
|
headers={"content-type": "image/png"}, request=__import__('httpx').Request('GET', url))
|
||||||
|
|
||||||
|
async def fake_post(*args, **kwargs): return {"image": image}
|
||||||
|
monkeypatch.setattr(et.httpx, "AsyncClient", Client)
|
||||||
|
monkeypatch.setattr(et, "_post", fake_post)
|
||||||
|
_, result = asyncio.run(et.AiEditImageTool().execute(
|
||||||
|
{"op": "denoise", "image": "/api/generated-image/abcdef123456.png", "place": False},
|
||||||
|
{"owner": "alice", "session_id": "authorized", "incognito": True},
|
||||||
|
))
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert seen and seen[0].endswith('/api/generated-image/abcdef123456.png')
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_editor_tool_incognito_does_not_persist(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
import src.agent_tools.editor_tools as et
|
||||||
|
import src.ai_interaction as ai
|
||||||
|
|
||||||
|
image = "data:image/png;base64,YWJj"
|
||||||
|
saved = []
|
||||||
|
|
||||||
|
async def fake_generate(*args, **kwargs):
|
||||||
|
assert kwargs["incognito"] is True
|
||||||
|
return {"image_url": image}
|
||||||
|
|
||||||
|
monkeypatch.setattr(et, "_provider_available", lambda owner: True)
|
||||||
|
monkeypatch.setattr(et, "_persist_png", lambda *args: saved.append(args) or {})
|
||||||
|
monkeypatch.setattr(ai, "do_generate_image", fake_generate)
|
||||||
|
_, result = asyncio.run(et.GenerateImageLayerTool().execute(
|
||||||
|
json.dumps({"prompt": "a tree", "place": False}),
|
||||||
|
{"owner": "u", "incognito": True},
|
||||||
|
))
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert not saved
|
||||||
|
assert result["image_url"] == image
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_image_is_removed_if_gallery_commit_fails(monkeypatch, tmp_path):
|
||||||
|
import core.database as db_module
|
||||||
|
import src.constants as constants
|
||||||
|
import src.agent_tools.editor_tools as et
|
||||||
|
|
||||||
|
class FailingSession:
|
||||||
|
def add(self, row):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
raise RuntimeError("commit failed")
|
||||||
|
|
||||||
|
def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(db_module, "SessionLocal", FailingSession)
|
||||||
|
monkeypatch.setattr(constants, "GENERATED_IMAGES_DIR", str(tmp_path))
|
||||||
|
with pytest.raises(RuntimeError, match="commit failed"):
|
||||||
|
et._persist_png(b"test image", "alice", None, "test", "test")
|
||||||
|
assert list(tmp_path.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
def test_agent_path_injects_selected_model_into_ai_step(client, monkeypatch):
|
def test_agent_path_injects_selected_model_into_ai_step(client, monkeypatch):
|
||||||
"""The /api/editor/agent body `model` reaches the AI step tool payload."""
|
"""The /api/editor/agent body `model` reaches the AI step tool payload."""
|
||||||
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
|
||||||
@@ -973,6 +1333,32 @@ def test_agent_path_injects_selected_model_into_ai_step(client, monkeypatch):
|
|||||||
assert data["results"][-1]["result"].get("model") == "qwen-image-edit-2511", data
|
assert data["results"][-1]["result"].get("model") == "qwen-image-edit-2511", data
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_request_controls_session_model_and_screenshot(client, monkeypatch):
|
||||||
|
from src.agent_tools import editor_tools
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class Capture:
|
||||||
|
async def execute(self, content, ctx):
|
||||||
|
captured.update(json.loads(content))
|
||||||
|
return "ai_edit_image", {"exit_code": 1, "error": "stopped"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(editor_tools, "AiEditImageTool", Capture)
|
||||||
|
monkeypatch.setattr(eaar, "_image_provider_available", lambda owner: True)
|
||||||
|
eaar.set_editor_planner(lambda messages, owner: {"steps": [{"op": "ai_edit_image", "params": {
|
||||||
|
"op": "generative", "session": "other", "model": "other", "image": "other"
|
||||||
|
}}]})
|
||||||
|
response = client.post("/api/editor/agent", json={"prompt": "do this", "session": "current",
|
||||||
|
"model": "chosen", "screenshot": "data:image/png;base64,AAAA"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert captured["session"] == "current"
|
||||||
|
assert captured["model"] == "chosen"
|
||||||
|
assert captured["image"] == "data:image/png;base64,AAAA"
|
||||||
|
captured.clear()
|
||||||
|
response = client.post("/api/editor/agent", json={"prompt": "do this", "session": "current"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "image" not in captured
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Editor agent/planner model (local Qwen3.5-9B) resolution + reporting.
|
# Editor agent/planner model (local Qwen3.5-9B) resolution + reporting.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1043,8 +1429,8 @@ def test_editor_agent_model_override_is_first_candidate(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"])
|
monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"])
|
||||||
monkeypatch.setattr(eam, "local_model", lambda: "LMODEL")
|
monkeypatch.setattr(eam, "local_model", lambda: "LMODEL")
|
||||||
cands = eam._candidates(None, "http://ovr::OVERRIDE")
|
cands = eam._candidates(None, "http://local1::OVERRIDE")
|
||||||
assert cands[0] == {"base": "http://ovr", "model": "OVERRIDE", "headers": None,
|
assert cands[0] == {"base": "http://local1", "model": "OVERRIDE", "headers": None,
|
||||||
"source": "override"}
|
"source": "override"}
|
||||||
assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands)
|
assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands)
|
||||||
|
|
||||||
@@ -1492,3 +1878,110 @@ def test_editor_agent_plan_response_format_only_sent_to_local(monkeypatch):
|
|||||||
assert calls and calls[0][0] == "http://100.69.120.65:8097/v1"
|
assert calls and calls[0][0] == "http://100.69.120.65:8097/v1"
|
||||||
assert calls[0][1] is fmt
|
assert calls[0][1] is fmt
|
||||||
assert all(base != "https://api.deepseek.com/v1" for base, _ in calls)
|
assert all(base != "https://api.deepseek.com/v1" for base, _ in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_editor_command_queued_never_reports_success(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from src.agent_tools import editor_tools
|
||||||
|
|
||||||
|
async def fake_post(path, payload, owner, timeout=60):
|
||||||
|
return {"queued": ["cmd-123"], "session": "anon:default", "status": "queued", "completed": False, "ok": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr(editor_tools, "_post", fake_post)
|
||||||
|
tool = editor_tools.RunEditorCommandTool()
|
||||||
|
label, result = asyncio.run(tool.execute(json.dumps({"op": "document.info", "wait": False}), {}))
|
||||||
|
|
||||||
|
assert result["status"] == "queued"
|
||||||
|
assert result["completed"] is False
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["exit_code"] == 1
|
||||||
|
assert result["pending"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_editor_command_completed_reports_success(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from src.agent_tools import editor_tools
|
||||||
|
|
||||||
|
async def fake_post(path, payload, owner, timeout=60):
|
||||||
|
return {"ok": True, "status": "completed", "completed": True, "result": {"info": "ok"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(editor_tools, "_post", fake_post)
|
||||||
|
tool = editor_tools.RunEditorCommandTool()
|
||||||
|
label, result = asyncio.run(tool.execute(json.dumps({"op": "document.info", "wait": True}), {}))
|
||||||
|
|
||||||
|
assert result["status"] == "completed"
|
||||||
|
assert result["completed"] is True
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["exit_code"] == 0
|
||||||
|
assert result["result"] == {"info": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_place_layer_reports_false_on_queued_response(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
from src.agent_tools import editor_tools
|
||||||
|
|
||||||
|
async def fake_post(path, payload, owner, timeout=90):
|
||||||
|
return {"queued": ["place-123"], "session": "anon:default", "status": "queued", "completed": False, "ok": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr(editor_tools, "_post", fake_post)
|
||||||
|
placed = asyncio.run(editor_tools._place_layer("data:image/png;base64,abc", "layer1", "owner", "default"))
|
||||||
|
assert placed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_bridge_await_cancellation_handled_cleanly():
|
||||||
|
import asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
app = _bridge_app()
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
cmd_resp = await c.post("/api/editor/bridge/command", json={"session": "cancel-sess", "op": "document.info", "wait": False})
|
||||||
|
cid = cmd_resp.json()["queued"][0]
|
||||||
|
|
||||||
|
await_task = asyncio.create_task(c.post("/api/editor/bridge/await", json={"id": cid, "timeout": 30}))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
await_task.cancel()
|
||||||
|
try:
|
||||||
|
await await_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_editor_routes_fail_closed_when_auth_configured(client, monkeypatch):
|
||||||
|
from types import SimpleNamespace
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
client.app.state.auth_manager = SimpleNamespace(is_configured=True)
|
||||||
|
r = client.post("/api/editor/bridge/command", json={"op": "document.info"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_disabled_mode_allows_single_user_access(client, monkeypatch):
|
||||||
|
import src.auth_helpers as auth_helpers
|
||||||
|
monkeypatch.setattr(auth_helpers, "_auth_disabled", lambda: True)
|
||||||
|
r = client.post("/api/editor/bridge/command", json={"op": "document.info", "wait": False})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_models_redact_base_url_for_non_admins(client, monkeypatch):
|
||||||
|
import core.middleware as core_middleware
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
def fake_require_admin(request):
|
||||||
|
raise HTTPException(403, "Admin required")
|
||||||
|
|
||||||
|
monkeypatch.setattr(core_middleware, "require_admin", fake_require_admin)
|
||||||
|
|
||||||
|
r_agent = client.get("/api/editor/agent-models")
|
||||||
|
assert r_agent.status_code == 200
|
||||||
|
agent_data = r_agent.json()
|
||||||
|
assert agent_data.get("probe") == {}
|
||||||
|
for m in agent_data.get("models", []):
|
||||||
|
assert "base_url" not in m
|
||||||
|
|
||||||
|
r_img = client.get("/api/editor/image-models")
|
||||||
|
assert r_img.status_code == 200
|
||||||
|
img_data = r_img.json()
|
||||||
|
for m in img_data.get("models", []):
|
||||||
|
assert "base_url" not in m
|
||||||
|
|||||||
@@ -81,3 +81,11 @@ def test_unconfigured_editor_helpers_do_not_probe_private_hosts(monkeypatch):
|
|||||||
assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None
|
assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None
|
||||||
result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter"))
|
result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter"))
|
||||||
assert result["reason"] == "unavailable"
|
assert result["reason"] == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured_or_untrusted_override_never_becomes_endpoint(monkeypatch):
|
||||||
|
monkeypatch.setattr(eam, "local_bases", lambda: [])
|
||||||
|
import src.endpoint_resolver as resolver
|
||||||
|
monkeypatch.setattr(resolver, "resolve_endpoint", lambda *a, **k: (None, None, None))
|
||||||
|
assert eam._candidates("alice", "chosen-model") == []
|
||||||
|
assert eam._candidates("alice", "http://169.254.169.254::model") == []
|
||||||
|
|||||||
@@ -67,6 +67,66 @@ def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatc
|
|||||||
assert not (result.get("sync") or {}).get("stale")
|
assert not (result.get("sync") or {}).get("stale")
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_refresh_does_not_serve_index_snapshot(monkeypatch):
|
||||||
|
endpoint = _list_endpoint()
|
||||||
|
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([{"uid": "11"}], 1, "now"))
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def offline(*args, **kwargs):
|
||||||
|
attempts.append(True)
|
||||||
|
raise RuntimeError("offline test")
|
||||||
|
|
||||||
|
monkeypatch.setattr(er, "_imap_connect", offline)
|
||||||
|
result = _call(endpoint, refresh=1)
|
||||||
|
assert attempts
|
||||||
|
assert not (result.get("sync") or {}).get("stale")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_invalidation_discards_stale_snapshot(monkeypatch):
|
||||||
|
endpoint = _list_endpoint()
|
||||||
|
state = dict(zip(endpoint.__code__.co_freevars, (cell.cell_contents for cell in endpoint.__closure__)))
|
||||||
|
key = ("account", "INBOX", "all", 50, 0, "", 0, "", "", "alice")
|
||||||
|
state["_list_cache_put"](key, {"emails": [{"uid": "11"}]})
|
||||||
|
state["_invalidate_list_cache"]("account", "INBOX")
|
||||||
|
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
|
||||||
|
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("offline")))
|
||||||
|
result = _call(endpoint, account_id="account", owner="alice")
|
||||||
|
assert not (result.get("sync") or {}).get("stale")
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalidation_prevents_inflight_refresh_from_restoring_stale_snapshot():
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
endpoint = _list_endpoint()
|
||||||
|
state = dict(zip(endpoint.__code__.co_freevars, (cell.cell_contents for cell in endpoint.__closure__)))
|
||||||
|
schedule = state["_schedule_list_refresh"]
|
||||||
|
cells = dict(zip(schedule.__code__.co_freevars, schedule.__closure__))
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
original = cells["_list_emails_sync"].cell_contents
|
||||||
|
|
||||||
|
def fake_fetch(*args):
|
||||||
|
started.set()
|
||||||
|
assert release.wait(3)
|
||||||
|
return {"emails": [{"uid": "stale"}]}
|
||||||
|
|
||||||
|
cells["_list_emails_sync"].cell_contents = fake_fetch
|
||||||
|
key = ("account", "INBOX", "all", 50, 0, "", 0, "", "", "alice")
|
||||||
|
try:
|
||||||
|
schedule((), key, "INBOX", "account", "alice")
|
||||||
|
assert started.wait(3)
|
||||||
|
state["_invalidate_list_cache"]("account", "INBOX")
|
||||||
|
release.set()
|
||||||
|
refreshing = cells["_LIST_REFRESHING"].cell_contents
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while key in refreshing and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.01)
|
||||||
|
assert key not in refreshing
|
||||||
|
assert state["_list_stale_get"](key) is None
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
cells["_list_emails_sync"].cell_contents = original
|
||||||
|
|
||||||
|
|
||||||
def test_swr_contract_is_wired_into_the_list_path():
|
def test_swr_contract_is_wired_into_the_list_path():
|
||||||
assert "_LIST_STALE" in SRC
|
assert "_LIST_STALE" in SRC
|
||||||
assert "def _list_stale_put" in SRC
|
assert "def _list_stale_put" in SRC
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ def png(image):
|
|||||||
return base64.b64encode(output.getvalue()).decode()
|
return base64.b64encode(output.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch):
|
def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch, caplog):
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
|
endpoint = SimpleNamespace(base_url='https://openrouter.ai/api/v1', api_key='test')
|
||||||
|
|
||||||
class Db:
|
class Db:
|
||||||
@@ -46,7 +48,7 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch):
|
|||||||
class Request:
|
class Request:
|
||||||
async def json(self):
|
async def json(self):
|
||||||
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image',
|
return {'_endpoint': endpoint.base_url, '_model': 'openai/gpt-5-image',
|
||||||
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'Make blue'}
|
'image': png(Image.new('RGB', (2, 1), 'red')), 'mask': png(mask), 'prompt': 'private-cloud-prompt'}
|
||||||
|
|
||||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||||
result = asyncio.run(route.endpoint(Request()))
|
result = asyncio.run(route.endpoint(Request()))
|
||||||
@@ -54,6 +56,7 @@ def test_openrouter_inpaint_keeps_unmasked_pixels(monkeypatch):
|
|||||||
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
|
assert output.getpixel((0, 0)) == (255, 0, 0, 255)
|
||||||
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
|
assert output.getpixel((1, 0)) == (0, 0, 255, 255)
|
||||||
assert len(requests) == 1
|
assert len(requests) == 1
|
||||||
|
assert 'private-cloud-prompt' not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
||||||
@@ -165,6 +168,19 @@ def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
|
|||||||
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
|
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inpaint_does_not_log_private_prompt_at_debug_level(monkeypatch, caplog):
|
||||||
|
import logging
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
secret = 'private-prompt-log-regression'
|
||||||
|
|
||||||
|
def respond(request):
|
||||||
|
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (4, 4), 'blue'))}]})
|
||||||
|
|
||||||
|
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (4, 4), 'red'),
|
||||||
|
Image.new('L', (4, 4), 255), {'prompt': secret}, respond)
|
||||||
|
assert secret not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
|
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
|
||||||
"""A whole-image instruction edit may omit the mask; it must not raise
|
"""A whole-image instruction edit may omit the mask; it must not raise
|
||||||
KeyError -> 400 (the health-audit regression)."""
|
KeyError -> 400 (the health-audit regression)."""
|
||||||
|
|||||||
@@ -129,6 +129,29 @@ def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
|
|||||||
raise AssertionError("expected a 502 when the provider returns no image")
|
raise AssertionError("expected a 502 when the provider returns no image")
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_timeout_is_reported_as_gateway_timeout(monkeypatch):
|
||||||
|
def timeout(_request):
|
||||||
|
raise httpx.ReadTimeout("local test timeout")
|
||||||
|
|
||||||
|
_setup(monkeypatch, timeout)
|
||||||
|
try:
|
||||||
|
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
|
||||||
|
except Exception as exc:
|
||||||
|
assert getattr(exc, "status_code", None) == 504
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected a 504 for a provider timeout")
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_provider_reply_is_a_gateway_error(monkeypatch):
|
||||||
|
_setup(monkeypatch, lambda _req: httpx.Response(200, content=b'not-json'))
|
||||||
|
try:
|
||||||
|
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
|
||||||
|
except Exception as exc:
|
||||||
|
assert getattr(exc, "status_code", None) == 502
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected a 502 for malformed provider JSON")
|
||||||
|
|
||||||
|
|
||||||
async def _async_return(value):
|
async def _async_return(value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ def test_recolor_phrase_cleanup_handles_long_whitespace(prompt, target, expected
|
|||||||
assert guide._recolor_object_phrase(prompt, target) == expected
|
assert guide._recolor_object_phrase(prompt, target) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_recolor_phrase_cleanup_handles_many_trailing_adjectives():
|
||||||
|
# Repeated regex substitutions previously rescanned the whole string for
|
||||||
|
# every adjective; this input would take minutes rather than linear time.
|
||||||
|
prompt = 'make the red car blue ' + 'bright ' * 100_000
|
||||||
|
assert guide._recolor_object_phrase(prompt, 'blue') == 'red car'
|
||||||
|
assert guide._recolor_object_phrase('make bright', '') == 'bright'
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_planner():
|
def _reset_planner():
|
||||||
eaar.set_editor_planner(None)
|
eaar.set_editor_planner(None)
|
||||||
|
|||||||
Reference in New Issue
Block a user