mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b118a9ccc | ||
|
|
3915efc9ac | ||
|
|
3ee93b6316 | ||
|
|
03d0042115 | ||
|
|
45e23c4615 | ||
|
|
f53ddfba3c | ||
|
|
763ae17766 | ||
|
|
e41e8354df | ||
|
|
79638043da |
@@ -53,8 +53,6 @@ services/data/
|
|||||||
|
|
||||||
# Test capture artifacts (browser session dumps may contain personal content)
|
# Test capture artifacts (browser session dumps may contain personal content)
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -35,15 +35,3 @@ regexTarget = "secret"
|
|||||||
regexes = [
|
regexes = [
|
||||||
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
|
||||||
]
|
]
|
||||||
|
|
||||||
[[allowlists]]
|
|
||||||
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
|
|
||||||
targetRules = ["generic-api-key"]
|
|
||||||
condition = "AND"
|
|
||||||
paths = [
|
|
||||||
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
|
|
||||||
]
|
|
||||||
regexTarget = "secret"
|
|
||||||
regexes = [
|
|
||||||
'''^sk-123456789$''',
|
|
||||||
]
|
|
||||||
|
|||||||
+3
-18
@@ -1,22 +1,9 @@
|
|||||||
# Both stages need the fixed native decoder, including curl in the builder and
|
|
||||||
# distro Node in the runtime. A cached Python base may predate the Debian fix.
|
|
||||||
FROM python:3.14.9-slim AS brotli-base
|
|
||||||
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
|
|
||||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
|
||||||
# Check inherited trust before the first HTTPS package request, retaining Debian's
|
|
||||||
# signed archive verification. Refresh only the explicit native dependency.
|
|
||||||
RUN python /usr/local/bin/check-ca-trust.py --system \
|
|
||||||
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
|
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
|
|
||||||
&& python /usr/local/bin/check-brotli.py \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
||||||
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
||||||
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
||||||
# the final image / Cookbook never has to compile the broken sdists. See
|
# the final image / Cookbook never has to compile the broken sdists. See
|
||||||
# docker/build-realesrgan-wheels.sh for the full rationale.
|
# docker/build-realesrgan-wheels.sh for the full rationale.
|
||||||
FROM brotli-base AS realesrgan-wheels
|
FROM python:3.14.9-slim AS realesrgan-wheels
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
|
||||||
@@ -27,7 +14,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
|
|||||||
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
||||||
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
||||||
|
|
||||||
FROM brotli-base
|
FROM python:3.14.9-slim
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
||||||
@@ -65,7 +52,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libglib2.0-0t64 \
|
libglib2.0-0t64 \
|
||||||
libxcb1 \
|
libxcb1 \
|
||||||
libmagic1 \
|
libmagic1 \
|
||||||
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Refresh both Python trust bundles before installing application dependencies.
|
# Refresh both Python trust bundles before installing application dependencies.
|
||||||
@@ -146,8 +132,7 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
|||||||
# Check gRPC's effective defaults after all Python dependencies are installed.
|
# Check gRPC's effective defaults after all Python dependencies are installed.
|
||||||
# The application minimum fixes its bundled roots; the checked override also
|
# The application minimum fixes its bundled roots; the checked override also
|
||||||
# keeps the effective Docker selection consistent with system trust.
|
# keeps the effective Docker selection consistent with system trust.
|
||||||
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
|
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
|
||||||
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
|
|
||||||
|
|
||||||
# Copy app code
|
# Copy app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -119,26 +119,19 @@ 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` and one step the call blocks and returns the editor's result.
|
With `wait:true` 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":8}'
|
-d '{"session":"default","op":"layers.addRaster","wait":true,"timeout":8000}'
|
||||||
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
# -> {"id":"...","ok":true,"result":{...},"screenshot":"data:image/png;base64,..."}
|
||||||
```
|
```
|
||||||
|
|
||||||
Session scoping uses the authenticated owner and session as separate values. A
|
Session scoping is `owner:session`, so an agent can only drive editors owned by the
|
||||||
bound editor-tool turn uses its server-owned session, not a model-selected one.
|
same user. The bridge is a **browser-only** flow: it requires an editor page to be open.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Optional dependencies — install only if you use the corresponding feature.
|
# Optional dependencies — install only if you use the corresponding feature.
|
||||||
# Local OCR for screenshots, scans, labels, and coordinate-grounded text extraction.
|
# Local OCR for screenshots, scans, labels, and coordinate-grounded text extraction.
|
||||||
rapidocr==3.10.0
|
rapidocr==3.9.2
|
||||||
onnxruntime>=1.31.0,<2
|
onnxruntime>=1.20,<2
|
||||||
# The app handles their absence gracefully (clear error message on first use).
|
# The app handles their absence gracefully (clear error message on first use).
|
||||||
#
|
#
|
||||||
# Note: chromadb-client + fastembed moved to requirements.txt — RAG, semantic
|
# Note: chromadb-client + fastembed moved to requirements.txt — RAG, semantic
|
||||||
@@ -46,7 +46,7 @@ PyMuPDF
|
|||||||
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
||||||
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
||||||
# the dependency-age discussion in issue #485.
|
# the dependency-age discussion in issue #485.
|
||||||
markitdown[docx,pptx,xlsx,xls]==0.1.9
|
markitdown[docx,pptx,xlsx,xls]==0.1.6
|
||||||
|
|
||||||
# Photoshop PSD opening / flattened previews / layer inspection.
|
# Photoshop PSD opening / flattened previews / layer inspection.
|
||||||
psd-tools
|
psd-tools
|
||||||
|
|||||||
+5
-5
@@ -5,9 +5,9 @@ python-dotenv
|
|||||||
httpx
|
httpx
|
||||||
# HTTPS clients using certifi must distrust the compromised eMudhra CA paths.
|
# HTTPS clients using certifi must distrust the compromised eMudhra CA paths.
|
||||||
certifi>=2026.10.23
|
certifi>=2026.10.23
|
||||||
httpcore>=1.0.9,<2.0
|
httpcore>=1.0,<2.0
|
||||||
pydantic>=2.14.1
|
pydantic>=2.13.4
|
||||||
pydantic-settings>=2.16.1
|
pydantic-settings>=2.14.1
|
||||||
SQLAlchemy
|
SQLAlchemy
|
||||||
pypdf
|
pypdf
|
||||||
pypdfium2
|
pypdfium2
|
||||||
@@ -25,7 +25,7 @@ numpy
|
|||||||
# ChromaDB service); fastembed runs local ONNX embeddings.
|
# ChromaDB service); fastembed runs local ONNX embeddings.
|
||||||
chromadb-client
|
chromadb-client
|
||||||
# gRPC vendors independent roots; 1.84.1 removes the incident trust anchors.
|
# gRPC vendors independent roots; 1.84.1 removes the incident trust anchors.
|
||||||
grpcio>=1.85.0
|
grpcio>=1.84.1
|
||||||
fastembed>=0.9.1
|
fastembed>=0.9.1
|
||||||
youtube-transcript-api
|
youtube-transcript-api
|
||||||
yt-dlp
|
yt-dlp
|
||||||
@@ -51,7 +51,7 @@ bcrypt
|
|||||||
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
||||||
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
||||||
# servers are migrated together.
|
# servers are migrated together.
|
||||||
mcp<3
|
mcp<2
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
croniter
|
croniter
|
||||||
|
|||||||
+21
-89
@@ -569,16 +569,6 @@ async def auto_name_session(session_manager, sess):
|
|||||||
if not first_msg:
|
if not first_msg:
|
||||||
return
|
return
|
||||||
|
|
||||||
from src.owner_identity import is_request_sentinel_owner
|
|
||||||
owner = getattr(sess, "owner", None)
|
|
||||||
invalid_owner = (
|
|
||||||
not isinstance(owner, str) or not owner.strip()
|
|
||||||
or owner != owner.strip() or is_request_sentinel_owner(owner)
|
|
||||||
)
|
|
||||||
if invalid_owner:
|
|
||||||
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
|
|
||||||
return
|
|
||||||
|
|
||||||
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
|
||||||
model_name = str(getattr(sess, "model", "") or "")
|
model_name = str(getattr(sess, "model", "") or "")
|
||||||
|
|
||||||
@@ -593,25 +583,15 @@ async def auto_name_session(session_manager, sess):
|
|||||||
except Exception:
|
except Exception:
|
||||||
_is_spec_model = False
|
_is_spec_model = False
|
||||||
if _is_spec_model and endpoint_url:
|
if _is_spec_model and endpoint_url:
|
||||||
try:
|
_headers = getattr(sess, "headers", None)
|
||||||
from src.database import SessionLocal
|
if isinstance(_headers, str):
|
||||||
from src.endpoint_resolver import (build_chat_url, build_headers,
|
|
||||||
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
|
|
||||||
from src.owner_identity import auth_disabled
|
|
||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
|
||||||
if not owner and not auth_disabled():
|
|
||||||
raise ValueError("Session has no owner")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
try:
|
||||||
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
_headers = json.loads(_headers)
|
||||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
except Exception:
|
||||||
title_url = build_chat_url(base)
|
_headers = None
|
||||||
title_headers = build_headers(key, base)
|
try:
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
title = await llm_call_async(
|
title = await llm_call_async(
|
||||||
title_url,
|
endpoint_url,
|
||||||
model_name,
|
model_name,
|
||||||
[
|
[
|
||||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
|
||||||
@@ -619,7 +599,7 @@ async def auto_name_session(session_manager, sess):
|
|||||||
],
|
],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=32,
|
max_tokens=32,
|
||||||
headers=title_headers,
|
headers=_headers or None,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
thinking_mode="off",
|
thinking_mode="off",
|
||||||
)
|
)
|
||||||
@@ -645,14 +625,6 @@ async def auto_name_session(session_manager, sess):
|
|||||||
return
|
return
|
||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
owner = getattr(sess, "owner", None)
|
||||||
from src.owner_identity import auth_disabled
|
|
||||||
if not owner and not auth_disabled():
|
|
||||||
title = fallback_session_title(first_msg)
|
|
||||||
session_manager.update_session_name(sess.id, title)
|
|
||||||
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
|
|
||||||
return
|
|
||||||
|
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
||||||
)
|
)
|
||||||
@@ -660,64 +632,24 @@ async def auto_name_session(session_manager, sess):
|
|||||||
logger.debug("[auto-name] No model provided, skipping")
|
logger.debug("[auto-name] No model provided, skipping")
|
||||||
return
|
return
|
||||||
|
|
||||||
sess_url = getattr(sess, "endpoint_url", None)
|
|
||||||
if t_url and sess_url and t_url == sess_url:
|
|
||||||
from src.database import SessionLocal
|
|
||||||
from src.endpoint_resolver import (
|
|
||||||
build_chat_url,
|
|
||||||
build_headers,
|
|
||||||
resolve_endpoint,
|
|
||||||
resolve_endpoint_runtime,
|
|
||||||
resolve_owner_registered_endpoint,
|
|
||||||
)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
endpoint = resolve_owner_registered_endpoint(db, t_url, owner)
|
|
||||||
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
|
|
||||||
t_url = build_chat_url(base)
|
|
||||||
t_headers = build_headers(key, base)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("[auto-name] session fallback endpoint %s unauthorized for %s: %s", t_url, owner, e)
|
|
||||||
t_url, t_model, t_headers = None, None, None
|
|
||||||
for prefix in ("utility", "default"):
|
|
||||||
try:
|
|
||||||
u, m, h = resolve_endpoint(prefix, owner=owner)
|
|
||||||
if u and m:
|
|
||||||
t_url, t_model, t_headers = u, m, h
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if not t_url or not t_model:
|
|
||||||
title = fallback_session_title(first_msg)
|
|
||||||
session_manager.update_session_name(sess.id, title)
|
|
||||||
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# max_tokens big enough that reasoning models (Minimax M2,
|
# max_tokens big enough that reasoning models (Minimax M2,
|
||||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||||
# plus the actual title — 200 used to clip them mid-reasoning
|
# plus the actual title — 200 used to clip them mid-reasoning
|
||||||
# so strip_think left an empty string and no rename happened.
|
# so strip_think left an empty string and no rename happened.
|
||||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||||
try:
|
title = await llm_call_async(
|
||||||
title = await llm_call_async(
|
t_url,
|
||||||
t_url,
|
t_model,
|
||||||
t_model,
|
[
|
||||||
[
|
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
||||||
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation — just the title."},
|
{"role": "user", "content": first_msg},
|
||||||
{"role": "user", "content": first_msg},
|
],
|
||||||
],
|
temperature=0.3,
|
||||||
temperature=0.3,
|
max_tokens=64,
|
||||||
max_tokens=64,
|
headers=t_headers,
|
||||||
headers=t_headers,
|
timeout=15,
|
||||||
timeout=15,
|
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
)
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
title = title.strip().strip('"\'').strip()
|
title = title.strip().strip('"\'').strip()
|
||||||
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
|
||||||
|
|||||||
@@ -331,7 +331,6 @@ def _is_external_discovery_request(text: str) -> bool:
|
|||||||
|
|
||||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
||||||
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
|
|
||||||
|
|
||||||
|
|
||||||
def _mentions_pdf_url(value: str) -> bool:
|
def _mentions_pdf_url(value: str) -> bool:
|
||||||
@@ -2694,15 +2693,11 @@ def setup_chat_routes(
|
|||||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||||
# image-edit chat turn) may carry the open image-editor session id and a
|
# image-edit chat turn) may carry the open image-editor session id and a
|
||||||
# canvas snapshot so the editor tool family can drive it in-process.
|
# canvas snapshot so the editor tool family can drive it in-process.
|
||||||
# The id is quoted into a system message, so accept only an
|
editor_session = (
|
||||||
# identifier; any other value is treated as no open editor.
|
|
||||||
editor_session = str(
|
|
||||||
form_data.get("editor_session")
|
form_data.get("editor_session")
|
||||||
or form_data.get("editor_session_id")
|
or form_data.get("editor_session_id")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
|
|
||||||
editor_session = ""
|
|
||||||
photo_editor_requested = (
|
photo_editor_requested = (
|
||||||
str(form_data.get("photo_editor") or "").strip().lower()
|
str(form_data.get("photo_editor") or "").strip().lower()
|
||||||
in {"1", "true", "yes", "on"}
|
in {"1", "true", "yes", "on"}
|
||||||
@@ -3526,7 +3521,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", "ai_edit_image"})
|
disabled_tools.update({"generate_image", "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):
|
||||||
|
|||||||
+94
-294
@@ -21,12 +21,11 @@ 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, HTTPException, Request
|
from fastapi import APIRouter, 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
|
||||||
@@ -1170,111 +1169,16 @@ 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
|
||||||
params = step.get("params")
|
out.append({"op": op, "params": step.get("params") or {}})
|
||||||
out.append({"op": op, "params": params if isinstance(params, dict) else {}})
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _json_object(request: Request) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
except (ValueError, UnicodeDecodeError):
|
|
||||||
raise HTTPException(400, "Expected a JSON object")
|
|
||||||
if not isinstance(body, dict):
|
|
||||||
raise HTTPException(400, "Expected a JSON object")
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt(value: Any) -> str:
|
|
||||||
text = str(value or "").strip()
|
|
||||||
if len(text) > 4096:
|
|
||||||
raise HTTPException(413, "Editor prompt is too long")
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def _bridge_timeout(value: Any) -> float:
|
|
||||||
try:
|
|
||||||
timeout = float(value or _BRIDGE_DEFAULT_TIMEOUT)
|
|
||||||
except (TypeError, ValueError, OverflowError):
|
|
||||||
raise HTTPException(400, "Invalid bridge timeout")
|
|
||||||
if not math.isfinite(timeout) or timeout <= 0 or timeout > 900:
|
|
||||||
raise HTTPException(400, "Invalid bridge timeout")
|
|
||||||
return timeout
|
|
||||||
|
|
||||||
|
|
||||||
def _is_admin(request: Request) -> bool:
|
|
||||||
"""Return True only for authorized administrator browser sessions or auth-disabled single-user mode.
|
|
||||||
|
|
||||||
Delegated credentials (bearer API tokens) are explicitly rejected:
|
|
||||||
per src/tool_security.py, bearer tokens are long-lived credentials handed to
|
|
||||||
third parties/scripts and must remain capped at non-admin policies even
|
|
||||||
when minted by an administrator.
|
|
||||||
"""
|
|
||||||
# 1. Delegated credentials (bearer API tokens) must NEVER receive admin metadata privileges
|
|
||||||
if getattr(request.state, "api_token", False):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 2. In-process internal tool bypass (loopback agent tools)
|
|
||||||
try:
|
|
||||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, INTERNAL_TOOL_USER
|
|
||||||
import secrets
|
|
||||||
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
|
||||||
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
|
||||||
return True
|
|
||||||
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. Explicitly auth-disabled single-user operation (AUTH_ENABLED=false)
|
|
||||||
try:
|
|
||||||
from src.auth_helpers import _auth_disabled
|
|
||||||
if _auth_disabled():
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 4. Middleware require_admin
|
|
||||||
try:
|
|
||||||
from core.middleware import require_admin
|
|
||||||
require_admin(request)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 5. Direct session/user check when request.state.current_user is set
|
|
||||||
try:
|
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
|
||||||
if auth_mgr and getattr(auth_mgr, "is_configured", False):
|
|
||||||
from src.auth_helpers import get_current_user
|
|
||||||
user = get_current_user(request) or getattr(request.state, "current_user", None)
|
|
||||||
if user and getattr(auth_mgr, "is_admin", None) and auth_mgr.is_admin(user):
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _user(request: Request) -> Optional[str]:
|
def _user(request: Request) -> Optional[str]:
|
||||||
# Handle API bearer tokens explicitly: require 'chat' scope and attribute to real owner
|
try:
|
||||||
if getattr(request.state, "api_token", False):
|
from src.auth_helpers import get_current_user
|
||||||
from src.auth_helpers import require_api_token_scope
|
return get_current_user(request)
|
||||||
return require_api_token_scope(request, "chat")
|
except Exception:
|
||||||
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]:
|
||||||
@@ -1302,14 +1206,17 @@ 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[Tuple[Optional[str], str], List["asyncio.Queue[dict]"]] = {}
|
subscribers: Dict[str, List["asyncio.Queue[dict]"]] = {}
|
||||||
backlog: Dict[Tuple[Optional[str], str], List[dict]] = {}
|
backlog: Dict[str, List[dict]] = {}
|
||||||
pending: Dict[str, Tuple[Optional[str], asyncio.Future]] = {}
|
pending: Dict[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)
|
||||||
@@ -1346,30 +1253,17 @@ 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 _expire_command(session, cid, fut):
|
def _scoped(session: Optional[str], owner: Optional[str]) -> str:
|
||||||
entry = pending.get(cid)
|
return f"{owner or 'anon'}:{session or 'default'}"
|
||||||
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)
|
||||||
body = await _json_object(request)
|
try:
|
||||||
prompt = _prompt(body.get("prompt"))
|
body = await request.json()
|
||||||
|
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")
|
||||||
@@ -1403,13 +1297,9 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
raw = None
|
raw = None
|
||||||
_used = editor_agent_model.get_last_model()
|
_used = editor_agent_model.get_last_model()
|
||||||
|
model_used = (_used[0] if _used and _used[0]
|
||||||
|
else (agent_model or editor_agent_model.local_model()))
|
||||||
parsed = _parse_plan(raw)
|
parsed = _parse_plan(raw)
|
||||||
if _used and _used[0]:
|
|
||||||
model_used = _used[0]
|
|
||||||
elif parsed:
|
|
||||||
model_used = agent_model or editor_agent_model.local_model()
|
|
||||||
else:
|
|
||||||
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
parsed["agent_model"] = model_used
|
parsed["agent_model"] = model_used
|
||||||
if mode != "check":
|
if mode != "check":
|
||||||
@@ -1473,49 +1363,31 @@ 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)
|
||||||
body = await _json_object(request)
|
try:
|
||||||
|
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 = _bridge_timeout(body.get("timeout"))
|
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||||
ids: List[str] = []
|
ids: List[str] = []
|
||||||
if len(pending) + len(steps) > max_pending:
|
wait_ids: List[str] = []
|
||||||
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)
|
||||||
fut = asyncio.get_event_loop().create_future()
|
if wait:
|
||||||
pending[cid] = (owner, fut)
|
pending[cid] = asyncio.get_event_loop().create_future()
|
||||||
|
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:
|
||||||
res = await asyncio.wait_for(fut, timeout=timeout)
|
result = await asyncio.wait_for(pending[wait_ids[0]], timeout=timeout)
|
||||||
if isinstance(res, dict):
|
return JSONResponse(result)
|
||||||
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:
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid}, status_code=504)
|
pending.pop(wait_ids[0], None)
|
||||||
finally:
|
return JSONResponse({"ok": False, "error": "timeout", "id": wait_ids[0]})
|
||||||
_expire_command(session, cid, fut)
|
return JSONResponse({"queued": ids, "session": session})
|
||||||
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:
|
||||||
@@ -1540,50 +1412,38 @@ 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:
|
||||||
owner = _user(request)
|
try:
|
||||||
body = await _json_object(request)
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
cid = str(body.get("id") or "")
|
cid = str(body.get("id") or "")
|
||||||
entry = pending.get(cid)
|
fut = pending.pop(cid, None)
|
||||||
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,
|
"id": cid, "ok": bool(body.get("ok")),
|
||||||
"ok": is_ok,
|
"result": body.get("result"), "error": body.get("error"),
|
||||||
"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:
|
||||||
owner = _user(request)
|
|
||||||
body = await _json_object(request)
|
|
||||||
cid = str(body.get("id") or "")
|
|
||||||
timeout = _bridge_timeout(body.get("timeout"))
|
|
||||||
entry = pending.get(cid)
|
|
||||||
if entry is None or entry[0] != owner:
|
|
||||||
return JSONResponse({"ok": False, "error": "unknown command", "status": "unknown", "completed": False}, status_code=404)
|
|
||||||
fut = entry[1]
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
|
body = await request.json()
|
||||||
if pending.get(cid) == (owner, fut):
|
except Exception:
|
||||||
pending.pop(cid, None)
|
body = {}
|
||||||
res = dict(result) if isinstance(result, dict) else {"result": result, "ok": True}
|
cid = str(body.get("id") or "")
|
||||||
res.setdefault("status", "completed" if res.get("ok") else "failed")
|
timeout = float(body.get("timeout") or _BRIDGE_DEFAULT_TIMEOUT)
|
||||||
res["completed"] = bool(res.get("ok"))
|
fut = pending.get(cid)
|
||||||
return JSONResponse(res)
|
if fut is None:
|
||||||
|
fut = asyncio.get_event_loop().create_future()
|
||||||
|
pending[cid] = fut
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(fut, timeout=timeout)
|
||||||
|
return JSONResponse(result)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return JSONResponse({"ok": False, "error": "timeout", "status": "timed_out", "completed": False, "id": cid})
|
pending.pop(cid, None)
|
||||||
except asyncio.CancelledError:
|
return JSONResponse({"ok": False, "error": "timeout", "id": cid})
|
||||||
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 "
|
||||||
@@ -1634,14 +1494,11 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
overwritten.
|
overwritten.
|
||||||
"""
|
"""
|
||||||
owner = _user(request)
|
owner = _user(request)
|
||||||
body = await _json_object(request)
|
try:
|
||||||
from src.settings import get_setting
|
body = await request.json()
|
||||||
disabled = get_setting("disabled_tools", []) or []
|
except Exception:
|
||||||
if not isinstance(disabled, (list, tuple, set)):
|
body = {}
|
||||||
disabled = []
|
prompt = str(body.get("prompt") or "").strip()
|
||||||
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")
|
||||||
@@ -1649,10 +1506,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 = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
tools_offered = _scoped_editor_tools()
|
||||||
scoped = _scoped(session, owner)
|
scoped = _scoped(session, owner)
|
||||||
trace: List[Dict[str, Any]] = [
|
trace: List[Dict[str, Any]] = [
|
||||||
{"type": "session", "session": _scope_label(scoped), "tools_offered": tools_offered},
|
{"type": "session", "session": scoped, "tools_offered": tools_offered},
|
||||||
]
|
]
|
||||||
if not prompt:
|
if not prompt:
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1683,14 +1540,9 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
raw = None
|
raw = None
|
||||||
_used = editor_agent_model.get_last_model()
|
_used = editor_agent_model.get_last_model()
|
||||||
parsed = _parse_plan(raw)
|
model_used = (_used[0] if _used and _used[0]
|
||||||
if _used and _used[0]:
|
else (agent_model or editor_agent_model.local_model()))
|
||||||
model_used = _used[0]
|
plan = _parse_plan(raw) or _fallback_plan(prompt)
|
||||||
elif parsed:
|
|
||||||
model_used = agent_model or editor_agent_model.local_model()
|
|
||||||
else:
|
|
||||||
model_used = "fallback" if agent_model else editor_agent_model.local_model()
|
|
||||||
plan = parsed or _fallback_plan(prompt)
|
|
||||||
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
|
_strengthened = style_guide.strengthen(prompt, list(plan.get("steps") or []))
|
||||||
if _strengthened != plan.get("steps"):
|
if _strengthened != plan.get("steps"):
|
||||||
plan = {**plan, "steps": _strengthened,
|
plan = {**plan, "steps": _strengthened,
|
||||||
@@ -1749,19 +1601,8 @@ 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({
|
||||||
@@ -1777,12 +1618,6 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
"results": [],
|
"results": [],
|
||||||
"agent_model": model_used,
|
"agent_model": model_used,
|
||||||
})
|
})
|
||||||
needed = {"plan_edit"}
|
|
||||||
for step in steps:
|
|
||||||
op = step.get("op")
|
|
||||||
needed.add(op if op in {"ai_edit_image", "generate_image"} else "run_editor_command")
|
|
||||||
if needed.intersection(disabled):
|
|
||||||
raise HTTPException(403, "Editor tool is disabled")
|
|
||||||
results: List[Dict[str, Any]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
for step in steps:
|
for step in steps:
|
||||||
op = step.get("op")
|
op = step.get("op")
|
||||||
@@ -1794,30 +1629,24 @@ 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)
|
||||||
# Planner output is untrusted: it cannot redirect this turn to
|
tool_payload.setdefault("session", session)
|
||||||
# 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["model"] = model_choice
|
tool_payload.setdefault("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["image"] = screenshot
|
tool_payload.setdefault("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": _scope_label(scoped), "editor_session": session,
|
{"owner": owner, "session_id": scoped},
|
||||||
"incognito": body.get("incognito") is True},
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
logger.exception("Editor image operation failed")
|
res = {"error": str(exc), "exit_code": 1}
|
||||||
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 = {
|
||||||
@@ -1835,17 +1664,15 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
cid = uuid.uuid4().hex
|
cid = uuid.uuid4().hex
|
||||||
fut = asyncio.get_event_loop().create_future()
|
pending[cid] = 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][1], timeout=_BRIDGE_DEFAULT_TIMEOUT)
|
res = await asyncio.wait_for(pending[cid], 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:
|
||||||
@@ -1856,16 +1683,13 @@ 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
|
||||||
_info_fut = asyncio.get_event_loop().create_future()
|
pending[_info_id] = 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][1], timeout=60)
|
_info = await asyncio.wait_for(pending[_info_id], 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:
|
||||||
@@ -1902,7 +1726,6 @@ 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:
|
||||||
@@ -1925,32 +1748,22 @@ 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"
|
||||||
endpoint_id = getattr(ep, "id", None)
|
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 (f"Endpoint {endpoint_id}" if endpoint_id 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
|
||||||
key = (str(endpoint_id or provider), name)
|
key = (provider, name)
|
||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
entry = {
|
models.append({
|
||||||
"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": endpoint_id,
|
"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:
|
||||||
@@ -1961,55 +1774,42 @@ def setup_editor_agent_routes() -> APIRouter:
|
|||||||
async def editor_agent_models(request: Request) -> JSONResponse:
|
async def editor_agent_models(request: Request) -> JSONResponse:
|
||||||
"""List the models the editor agent/planner can run on.
|
"""List the models the editor agent/planner can run on.
|
||||||
|
|
||||||
Each entry is ``{id, label, source, vision}`` where ``id`` is
|
Each entry is ``{id, label, source, vision, base_url}`` where ``id`` is
|
||||||
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
|
passed as ``agent_model`` to /api/editor/plan or /api/editor/agent, and
|
||||||
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
|
``source`` is ``local`` (the local Qwen3.5-9B vision server) or
|
||||||
``hosted`` (the configured default/utility chat model that the planner
|
``hosted`` (the configured default/utility chat model that the planner
|
||||||
falls back to).
|
falls back to).
|
||||||
For non-admin users, private URLs and internal probe status are omitted.
|
|
||||||
"""
|
"""
|
||||||
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": probe_info,
|
"probe": editor_agent_model.probe_status(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@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.
|
||||||
|
|
||||||
A configured vision server may not provide browser CORS headers. This route
|
The local VL server (seneca :8097) sends no CORS headers, so the browser
|
||||||
|
cannot call it directly and the self-check silently "passed". This route
|
||||||
proxies the two images + intent to the agent VLM and returns
|
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)
|
||||||
body = await _json_object(request)
|
try:
|
||||||
|
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"),
|
||||||
_prompt(body.get("prompt") or body.get("intent")),
|
str(body.get("prompt") or body.get("intent") or ""),
|
||||||
model=str(body.get("model") or "").strip() or None,
|
model=str(body.get("model") or "").strip() or None,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)
|
)
|
||||||
|
|||||||
+73
-133
@@ -1827,25 +1827,18 @@ 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)
|
||||||
|
|
||||||
_LIST_STALE = {}
|
|
||||||
_LIST_LOCK = _threading.RLock()
|
|
||||||
_LIST_CACHE_LOCK = _LIST_LOCK
|
|
||||||
# Invalidation fences reads already in flight, including background refresh.
|
|
||||||
_LIST_EPOCH = [0]
|
|
||||||
_LIST_CACHE_GENERATION = _LIST_EPOCH
|
|
||||||
|
|
||||||
def _list_cache_get(key):
|
def _list_cache_get(key):
|
||||||
with _LIST_LOCK:
|
v = _LIST_CACHE.get(key)
|
||||||
v = _LIST_CACHE.get(key)
|
if not v: return None
|
||||||
if not v: return None
|
if v[0] < _time.monotonic():
|
||||||
if v[0] < _time.monotonic():
|
_LIST_CACHE.pop(key, None)
|
||||||
_LIST_CACHE.pop(key, None)
|
return None
|
||||||
return None
|
return v[1]
|
||||||
return v[1]
|
|
||||||
|
_LIST_STALE = {}
|
||||||
|
|
||||||
def _list_stale_get(key):
|
def _list_stale_get(key):
|
||||||
with _LIST_LOCK:
|
return _LIST_STALE.get(key)
|
||||||
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
|
||||||
@@ -1856,13 +1849,13 @@ def setup_email_routes():
|
|||||||
_LIST_REFRESHING = set()
|
_LIST_REFRESHING = set()
|
||||||
|
|
||||||
def _list_cache_put(key, value):
|
def _list_cache_put(key, value):
|
||||||
with _LIST_LOCK:
|
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
||||||
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
|
# Also snapshot the last-good response for stale-while-revalidate.
|
||||||
# Also snapshot the last-good response for stale-while-revalidate.
|
_list_stale_put(key, value)
|
||||||
_list_stale_put(key, value)
|
# Cap size
|
||||||
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.
|
||||||
@@ -1870,20 +1863,15 @@ 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.
|
||||||
"""
|
"""
|
||||||
with _LIST_LOCK:
|
if cache_key in _LIST_REFRESHING:
|
||||||
if cache_key in _LIST_REFRESHING:
|
return
|
||||||
return
|
_LIST_REFRESHING.add(cache_key)
|
||||||
_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"):
|
||||||
with _LIST_LOCK:
|
_list_cache_put(cache_key, refreshed)
|
||||||
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)
|
||||||
@@ -1892,8 +1880,7 @@ 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:
|
||||||
with _LIST_LOCK:
|
_LIST_REFRESHING.discard(cache_key)
|
||||||
_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()
|
||||||
|
|
||||||
@@ -1930,74 +1917,37 @@ def setup_email_routes():
|
|||||||
hook(account_id, folder)
|
hook(account_id, folder)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
with _LIST_LOCK:
|
if account_id is None and folder is None:
|
||||||
_LIST_EPOCH[0] += 1
|
_LIST_CACHE.clear()
|
||||||
if account_id is None and folder is None:
|
return
|
||||||
_LIST_CACHE.clear()
|
for k in list(_LIST_CACHE.keys()):
|
||||||
_LIST_STALE.clear()
|
k_acct = k[0] if len(k) > 0 else ""
|
||||||
return
|
k_folder = k[1] if len(k) > 1 else ""
|
||||||
for cache in (_LIST_CACHE, _LIST_STALE):
|
if (account_id is None or k_acct == (account_id or "")) and \
|
||||||
for k in list(cache.keys()):
|
(folder is None or k_folder == folder):
|
||||||
k_acct = k[0] if len(k) > 0 else ""
|
_LIST_CACHE.pop(k, None)
|
||||||
k_folder = k[1] if len(k) > 1 else ""
|
|
||||||
if (account_id is None or k_acct == (account_id or "")) and \
|
|
||||||
(folder is None or k_folder == folder):
|
|
||||||
cache.pop(k, None)
|
|
||||||
|
|
||||||
def _index_update_flags(owner, account_id, folder, uid, flag, add):
|
|
||||||
with _LIST_LOCK:
|
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
|
|
||||||
_invalidate_list_cache(account_id, folder)
|
|
||||||
|
|
||||||
def _index_delete(owner, account_id, folder, uid):
|
|
||||||
with _LIST_LOCK:
|
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
|
||||||
_invalidate_list_cache(account_id, folder)
|
|
||||||
|
|
||||||
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)
|
||||||
with _LIST_LOCK:
|
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
||||||
for key, (expires_at, value) in list(_LIST_CACHE.items()):
|
if key[0] != (account_id or "") or key[1] != folder:
|
||||||
if key[0] != (account_id or "") or key[1] != folder:
|
continue
|
||||||
continue
|
emails = list((value or {}).get("emails") or [])
|
||||||
emails = list((value or {}).get("emails") or [])
|
changed = False
|
||||||
changed = False
|
if seen and key[2] == "unread":
|
||||||
if seen and key[2] == "unread":
|
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
||||||
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
|
if len(kept) != len(emails):
|
||||||
if len(kept) != len(emails):
|
value = dict(value)
|
||||||
value = dict(value)
|
value["emails"] = kept
|
||||||
value["emails"] = kept
|
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(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
|
changed = True
|
||||||
else:
|
if changed:
|
||||||
for e in emails:
|
_LIST_CACHE[key] = (expires_at, value)
|
||||||
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)
|
||||||
@@ -2377,8 +2327,6 @@ def setup_email_routes():
|
|||||||
SECURITY: `owner` is propagated so when `account_id` is missing,
|
SECURITY: `owner` is propagated so when `account_id` is missing,
|
||||||
the fallback config lookup is scoped to this user's accounts only.
|
the fallback config lookup is scoped to this user's accounts only.
|
||||||
"""
|
"""
|
||||||
with _LIST_CACHE_LOCK:
|
|
||||||
index_generation = _LIST_CACHE_GENERATION[0]
|
|
||||||
conn = None
|
conn = None
|
||||||
conn_ok = False
|
conn_ok = False
|
||||||
try:
|
try:
|
||||||
@@ -2673,9 +2621,7 @@ def setup_email_routes():
|
|||||||
if parsed:
|
if parsed:
|
||||||
fetched_emails.append(parsed)
|
fetched_emails.append(parsed)
|
||||||
cached_rows[parsed["uid"]] = parsed
|
cached_rows[parsed["uid"]] = parsed
|
||||||
with _LIST_CACHE_LOCK:
|
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
||||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
|
||||||
_email_index_upsert(owner, account_id, folder, fetched_emails)
|
|
||||||
for uid_num in uid_order:
|
for uid_num in uid_order:
|
||||||
row = cached_rows.get(uid_num)
|
row = cached_rows.get(uid_num)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -2969,7 +2915,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 manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
|
if not from_addr and not has_attachments and not (date_from or date_to):
|
||||||
snapshot = _list_stale_get(ck)
|
snapshot = _list_stale_get(ck)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
try:
|
try:
|
||||||
@@ -3004,8 +2950,6 @@ def setup_email_routes():
|
|||||||
ck, folder, account_id, owner,
|
ck, folder, account_id, owner,
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
with _LIST_CACHE_LOCK:
|
|
||||||
cache_generation = _LIST_CACHE_GENERATION[0]
|
|
||||||
result = await _asyncio.to_thread(
|
result = await _asyncio.to_thread(
|
||||||
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
|
||||||
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
|
||||||
@@ -3014,9 +2958,7 @@ def setup_email_routes():
|
|||||||
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
|
||||||
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
|
||||||
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
|
||||||
with _LIST_CACHE_LOCK:
|
_list_cache_put(ck, result)
|
||||||
if cache_generation == _LIST_CACHE_GENERATION[0]:
|
|
||||||
_list_cache_put(ck, result)
|
|
||||||
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
|
||||||
if elapsed_ms > 1500:
|
if elapsed_ms > 1500:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -3364,7 +3306,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
moved = _move_email_message(conn, uid, "Junk", role="junk")
|
||||||
if moved:
|
if moved:
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
|
||||||
@@ -3381,7 +3323,7 @@ def setup_email_routes():
|
|||||||
if deleted:
|
if deleted:
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
if deleted:
|
if deleted:
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
delete_error = "Email was unsubscribed but could not be moved to Trash"
|
||||||
@@ -3463,7 +3405,7 @@ def setup_email_routes():
|
|||||||
if moved:
|
if moved:
|
||||||
changed += 1
|
changed += 1
|
||||||
cleaned_uids.append(uid)
|
cleaned_uids.append(uid)
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
failed += 1
|
failed += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -3552,8 +3494,6 @@ def setup_email_routes():
|
|||||||
folder (Gmail does), we transparently swap to All Mail so the
|
folder (Gmail does), we transparently swap to All Mail so the
|
||||||
search surfaces archived / labelled emails too. Plain IMAP
|
search surfaces archived / labelled emails too. Plain IMAP
|
||||||
accounts fall back to whatever folder the caller specified."""
|
accounts fall back to whatever folder the caller specified."""
|
||||||
with _LIST_CACHE_LOCK:
|
|
||||||
index_generation = _LIST_CACHE_GENERATION[0]
|
|
||||||
if not q or len(q) < 2:
|
if not q or len(q) < 2:
|
||||||
return {"emails": [], "total": 0, "query": q}
|
return {"emails": [], "total": 0, "query": q}
|
||||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||||
@@ -3697,9 +3637,7 @@ def setup_email_routes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||||
continue
|
continue
|
||||||
with _LIST_CACHE_LOCK:
|
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
||||||
if index_generation == _LIST_CACHE_GENERATION[0]:
|
|
||||||
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"emails": emails,
|
"emails": emails,
|
||||||
@@ -3836,7 +3774,8 @@ def setup_email_routes():
|
|||||||
# accepted it, so the index and list cache cannot drift ahead of
|
# accepted it, so the index and list cache cannot drift ahead of
|
||||||
# the mailbox.
|
# the mailbox.
|
||||||
if mark_seen and not mark_seen_failed:
|
if mark_seen and not mark_seen_failed:
|
||||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
|
_update_list_cache_seen(account_id, folder, uid, True)
|
||||||
|
|
||||||
related_attachments = []
|
related_attachments = []
|
||||||
if full and not _has_visible_attachments(msg):
|
if full and not _has_visible_attachments(msg):
|
||||||
@@ -3975,7 +3914,8 @@ def setup_email_routes():
|
|||||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return False
|
return False
|
||||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
|
_update_list_cache_seen(account_id, folder, uid, True)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||||
@@ -5207,7 +5147,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
if not _store_email_flag(conn, uid, "\\Seen", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5227,7 +5167,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "flagged": bool(on)}
|
return {"success": True, "flagged": bool(on)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5245,7 +5185,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
if not _store_email_flag(conn, uid, "\\Seen", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5265,7 +5205,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
if not _move_email_message(conn, uid, "Archive", role="archive"):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5296,7 +5236,7 @@ def setup_email_routes():
|
|||||||
# click or by another mailbox client; it is already gone
|
# click or by another mailbox client; it is already gone
|
||||||
# from the requested folder, so do not trap the UI on a
|
# from the requested folder, so do not trap the UI on a
|
||||||
# permanent “Email not found” error.
|
# permanent “Email not found” error.
|
||||||
_index_delete(owner, account_id, folder, str(uid))
|
_email_index_delete(owner, account_id, folder, str(uid))
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True, "already_deleted": True}
|
return {"success": True, "already_deleted": True}
|
||||||
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
|
||||||
@@ -5314,9 +5254,9 @@ def setup_email_routes():
|
|||||||
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
|
||||||
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
|
||||||
return {"success": False, "error": "Could not move email to Trash"}
|
return {"success": False, "error": "Could not move email to Trash"}
|
||||||
_index_delete(owner, account_id, folder, resolved_uid)
|
_email_index_delete(owner, account_id, folder, resolved_uid)
|
||||||
if resolved_uid != str(uid):
|
if resolved_uid != str(uid):
|
||||||
_index_delete(owner, account_id, folder, str(uid))
|
_email_index_delete(owner, account_id, folder, str(uid))
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5332,7 +5272,7 @@ def setup_email_routes():
|
|||||||
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
conn.expunge()
|
conn.expunge()
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5400,7 +5340,7 @@ def setup_email_routes():
|
|||||||
pending_expunge.append(uid)
|
pending_expunge.append(uid)
|
||||||
continue
|
continue
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
except Exception:
|
except Exception:
|
||||||
failed_uids.append(uid)
|
failed_uids.append(uid)
|
||||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||||
@@ -5422,7 +5362,7 @@ def setup_email_routes():
|
|||||||
if expunge_ok:
|
if expunge_ok:
|
||||||
for uid in pending_expunge:
|
for uid in pending_expunge:
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
else:
|
else:
|
||||||
# Prioritize preservation over cleanup: unflag \Deleted from our
|
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||||
# copied messages so future EXPUNGE operations do not destroy them.
|
# copied messages so future EXPUNGE operations do not destroy them.
|
||||||
@@ -5541,7 +5481,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _move_email_message(conn, uid, dest):
|
if not _move_email_message(conn, uid, dest):
|
||||||
return {"success": False, "error": f"Failed to move to {dest}"}
|
return {"success": False, "error": f"Failed to move to {dest}"}
|
||||||
_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id)
|
_invalidate_list_cache(account_id)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -5627,7 +5567,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
if not _store_email_flag(conn, uid, "\\Answered", add=True):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
|
||||||
_clear_done_response_tags(owner, account_id, folder, uid)
|
_clear_done_response_tags(owner, account_id, folder, uid)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
@@ -5643,7 +5583,7 @@ def setup_email_routes():
|
|||||||
conn.select(_q(folder))
|
conn.select(_q(folder))
|
||||||
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
if not _store_email_flag(conn, uid, "\\Answered", add=False):
|
||||||
return {"success": False, "error": "Email not found"}
|
return {"success": False, "error": "Email not found"}
|
||||||
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
|
||||||
_invalidate_list_cache(account_id, folder)
|
_invalidate_list_cache(account_id, folder)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -6310,7 +6250,7 @@ def setup_email_routes():
|
|||||||
try:
|
try:
|
||||||
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
st, _sel = imap.select(_q(_source_folder), readonly=False)
|
||||||
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
|
||||||
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
|
||||||
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
|
||||||
_invalidate_list_cache(_account_id, _source_folder)
|
_invalidate_list_cache(_account_id, _source_folder)
|
||||||
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
|
||||||
|
|||||||
@@ -683,15 +683,9 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None, model: st
|
|||||||
configured.update(v for v in values if isinstance(v, str))
|
configured.update(v for v in values if isinstance(v, str))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
if model:
|
if model not in configured or not looks_like_image_generation_model(model):
|
||||||
if model not in configured or not looks_like_image_generation_model(model):
|
continue
|
||||||
continue
|
if _normalize_image_endpoint_base(getattr(ep, "base_url", "")) == target:
|
||||||
else:
|
|
||||||
if not any(looks_like_image_generation_model(v) for v in configured):
|
|
||||||
continue
|
|
||||||
ep_id = str(getattr(ep, "id", "") or "")
|
|
||||||
ep_base = _normalize_image_endpoint_base(getattr(ep, "base_url", ""))
|
|
||||||
if (ep_id and (base == ep_id or target == ep_id)) or (ep_base and ep_base == target):
|
|
||||||
if owner and getattr(ep, "owner", None) == owner:
|
if owner and getattr(ep, "owner", None) == owner:
|
||||||
return ep
|
return ep
|
||||||
if fallback is None:
|
if fallback is None:
|
||||||
@@ -718,7 +712,7 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
|||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
||||||
async with httpx.AsyncClient(timeout=60, follow_redirects=False) as c2:
|
async with httpx.AsyncClient(timeout=60) as c2:
|
||||||
ir = await c2.get(url)
|
ir = await c2.get(url)
|
||||||
if ir.status_code == 200:
|
if ir.status_code == 200:
|
||||||
return base64.b64encode(ir.content).decode()
|
return base64.b64encode(ir.content).decode()
|
||||||
@@ -785,17 +779,14 @@ 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)
|
||||||
try:
|
result = r.json()
|
||||||
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] if isinstance(data[0], dict) else {}
|
item = data[0] or {}
|
||||||
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"])
|
||||||
@@ -1736,7 +1727,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||||
# SSRF hardening: validate a client-supplied endpoint before any
|
# SSRF hardening: validate a client-supplied endpoint before any
|
||||||
# outbound request (mirrors routes/embedding_routes.py).
|
# outbound request (mirrors routes/embedding_routes.py).
|
||||||
if requested_base and "://" in requested_base:
|
if requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -1851,8 +1842,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 -> model=%r size=%r base=%s",
|
"inpaint_proxy provider edit -> prompt=%r model=%r size=%r base=%s",
|
||||||
oa_model, size, base,
|
body.get("prompt", ""), 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:
|
||||||
@@ -1987,9 +1978,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 -> model=%r "
|
"inpaint_proxy self-hosted /images/edits -> prompt=%r model=%r "
|
||||||
"size=%r steps=%r endpoint=%s",
|
"size=%r steps=%r endpoint=%s",
|
||||||
data.get("model"), data.get("size"),
|
data.get("prompt"), data.get("model"), data.get("size"),
|
||||||
data.get("steps"), base,
|
data.get("steps"), base,
|
||||||
)
|
)
|
||||||
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
_prep_ms = (time.perf_counter() - _t0) * 1000.0
|
||||||
@@ -2082,7 +2073,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
runs off the event loop (large images).
|
runs off the event loop (large images).
|
||||||
"""
|
"""
|
||||||
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
requested_base = (body.pop("_endpoint", "") or "").rstrip("/")
|
||||||
if requested_base and "://" in requested_base:
|
if requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -2175,38 +2166,26 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
try:
|
try:
|
||||||
ep = None
|
ep = None
|
||||||
if endpoint_id:
|
if endpoint_id:
|
||||||
ep = _visible_image_endpoint_for_base(db, endpoint_id, user)
|
ep = owner_filter(
|
||||||
if ep is None:
|
db.query(ModelEndpoint).filter(ModelEndpoint.id == endpoint_id),
|
||||||
return {"available": False, "reason": "endpoint not found or disabled"}
|
ModelEndpoint, user,
|
||||||
elif requested_base:
|
).first()
|
||||||
|
if ep is None and requested_base:
|
||||||
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
return {"available": False, "reason": "endpoint not found or disabled"}
|
|
||||||
else:
|
|
||||||
ep = _first_visible_image_endpoint(db, user)
|
ep = _first_visible_image_endpoint(db, user)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
return {"available": False, "reason": "no image endpoint configured"}
|
return {"available": False, "reason": "no image endpoint configured"}
|
||||||
base = ep.base_url.rstrip("/")
|
base = ep.base_url.rstrip("/")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
from src.url_safety import check_outbound_url
|
|
||||||
block_private = os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true"
|
|
||||||
ok, reason = check_outbound_url(base, block_private=block_private)
|
|
||||||
if not ok:
|
|
||||||
# CodeQL #1203: Never expose internal addresses, resolver errors or raw
|
|
||||||
# exception details to clients or logs. Use a stable diagnostic message
|
|
||||||
# without interpolating untrusted exception content or destination strings.
|
|
||||||
logger.warning("edit-progress endpoint rejected: outbound URL policy check failed")
|
|
||||||
return {"available": False, "reason": "endpoint URL rejected"}
|
|
||||||
|
|
||||||
if not base.endswith("/v1"):
|
if not base.endswith("/v1"):
|
||||||
base += "/v1"
|
base += "/v1"
|
||||||
# Dynamic path segment (validated token above), so not routed through
|
# Dynamic path segment (validated token above), so not routed through
|
||||||
# _join_checked_gallery_endpoint's exact-match whitelist.
|
# _join_checked_gallery_endpoint's exact-match whitelist.
|
||||||
url = f"{base}/images/progress/{request_id}"
|
url = f"{base}/images/progress/{request_id}"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
r = await client.get(url)
|
r = await client.get(url)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -2244,7 +2223,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# below, so validate it first (mirrors routes/embedding_routes.py).
|
# below, so validate it first (mirrors routes/embedding_routes.py).
|
||||||
# Local-first means loopback/LAN is allowed by default; the cloud
|
# Local-first means loopback/LAN is allowed by default; the cloud
|
||||||
# metadata range and non-HTTP(S) schemes are always rejected.
|
# metadata range and non-HTTP(S) schemes are always rejected.
|
||||||
if requested_base and "://" in requested_base:
|
if requested_base:
|
||||||
from src.url_safety import check_outbound_url
|
from src.url_safety import check_outbound_url
|
||||||
ok, reason = check_outbound_url(
|
ok, reason = check_outbound_url(
|
||||||
requested_base,
|
requested_base,
|
||||||
@@ -2271,7 +2250,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
# so the outbound URL never depends directly on request-body input.
|
# so the outbound URL never depends directly on request-body input.
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = _visible_image_endpoint_for_base(db, requested_base, user, model)
|
ep = _visible_image_endpoint_for_base(db, requested_base, user)
|
||||||
if not ep:
|
if not ep:
|
||||||
raise HTTPException(403, "Choose a registered image endpoint")
|
raise HTTPException(403, "Choose a registered image endpoint")
|
||||||
base = ep.base_url.rstrip("/")
|
base = ep.base_url.rstrip("/")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import html
|
import html
|
||||||
import secrets
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Form, HTTPException, Request
|
from fastapi import APIRouter, Form, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
@@ -13,7 +12,7 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from core.database import McpServer, SessionLocal
|
from core.database import McpServer, SessionLocal
|
||||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
|
from core.middleware import require_admin
|
||||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||||
from src.mcp_manager import McpManager
|
from src.mcp_manager import McpManager
|
||||||
|
|
||||||
@@ -425,11 +424,6 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||||
"""
|
"""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
|
||||||
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
|
||||||
# Same boundary as POST /api/tools: an agent turn must not be able
|
|
||||||
# to re-enable MCP tools an admin switched off.
|
|
||||||
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import json
|
|||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import socket
|
import socket
|
||||||
import secrets
|
|
||||||
import time as _time
|
import time as _time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
@@ -3092,12 +3091,6 @@ def setup_model_routes(model_discovery):
|
|||||||
def update_tools(body: ToolsUpdate, request: Request):
|
def update_tools(body: ToolsUpdate, request: Request):
|
||||||
"""Update which tools are disabled."""
|
"""Update which tools are disabled."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
|
||||||
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
|
|
||||||
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
|
|
||||||
# Owner impersonation can carry an admin username, so check the
|
|
||||||
# authenticated transport rather than request.state.current_user.
|
|
||||||
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
|
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
settings["disabled_tools"] = body.disabled
|
settings["disabled_tools"] = body.disabled
|
||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
|
|||||||
@@ -290,9 +290,14 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
|||||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||||
# explicit empty status so loading a session does not log a spurious
|
# explicit empty status so loading a session does not log a spurious
|
||||||
# 404; every client treats a non-"running" status as idle anyway.
|
# 404; every client treats a non-"running" status as idle anyway.
|
||||||
# Research owned by ANOTHER user answers identically, so the response
|
# A session whose research exists but belongs to ANOTHER user stays a
|
||||||
# does not reveal whether that research exists.
|
# 404, so we do not leak the existence of someone else's research.
|
||||||
if not _owns_in_memory(session_id, user):
|
if not _owns_in_memory(session_id, user):
|
||||||
|
exists = session_id in getattr(research_handler, "_active_tasks", {})
|
||||||
|
if not exists:
|
||||||
|
exists = _find_research_path(session_id) is not None
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(404, "No research found for this session")
|
||||||
return {"status": "none"}
|
return {"status": "none"}
|
||||||
status = research_handler.get_status(session_id)
|
status = research_handler.get_status(session_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
|
|||||||
+45
-91
@@ -25,12 +25,11 @@ from src.auth_helpers import effective_user, is_delegated_credential
|
|||||||
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
from src.owner_identity import auth_disabled, is_request_sentinel_owner
|
||||||
from src.attachment_refs import attachment_refs_from_metadata
|
from src.attachment_refs import attachment_refs_from_metadata
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
|
from src.path_confinement import is_inside
|
||||||
from src.upload_handler import (
|
from src.upload_handler import (
|
||||||
UploadCleanupSafetyError,
|
UploadCleanupSafetyError,
|
||||||
count_recent_uploads,
|
count_recent_uploads,
|
||||||
extract_upload_ids,
|
extract_upload_ids,
|
||||||
secure_filename,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -154,47 +153,30 @@ def setup_upload_routes(upload_handler):
|
|||||||
|
|
||||||
def _upload_root() -> str:
|
def _upload_root() -> str:
|
||||||
from src.constants import UPLOAD_DIR
|
from src.constants import UPLOAD_DIR
|
||||||
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
||||||
|
|
||||||
def _path_inside_upload_dir(path: str) -> bool:
|
def _path_inside_upload_dir(path: str) -> bool:
|
||||||
return is_inside(_upload_root(), path)
|
return is_inside(_upload_root(), path)
|
||||||
|
|
||||||
def _resolve_upload_path(file_id: str) -> str:
|
def _resolve_upload_path(file_id: str) -> str:
|
||||||
safe_id = secure_filename(os.path.basename(file_id))
|
from src.constants import UPLOAD_DIR
|
||||||
if not safe_id:
|
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
|
||||||
raise HTTPException(400, "Invalid file ID")
|
direct = os.path.join(upload_root, file_id)
|
||||||
root = _upload_root()
|
|
||||||
|
|
||||||
direct = os.path.normpath(os.path.join(root, safe_id))
|
|
||||||
if os.path.lexists(direct):
|
if os.path.lexists(direct):
|
||||||
try:
|
if not _path_inside_upload_dir(direct):
|
||||||
canonical_direct = confine(root, direct, allow_root=False)
|
|
||||||
except (PathEscape, ValueError):
|
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
if os.path.isfile(canonical_direct):
|
if os.path.isfile(direct):
|
||||||
return canonical_direct
|
return direct
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
info = _load_upload_info(safe_id)
|
for root, _dirs, files in os.walk(upload_root, followlinks=False):
|
||||||
stored_path = (info or {}).get("path")
|
if file_id not in files:
|
||||||
if stored_path:
|
|
||||||
try:
|
|
||||||
canonical_stored = confine(root, stored_path, allow_root=False)
|
|
||||||
if os.path.isfile(canonical_stored):
|
|
||||||
return canonical_stored
|
|
||||||
except (PathEscape, ValueError):
|
|
||||||
raise HTTPException(403, "Access denied")
|
|
||||||
|
|
||||||
for walk_root, _dirs, files in os.walk(root, followlinks=False):
|
|
||||||
if safe_id not in files:
|
|
||||||
continue
|
continue
|
||||||
cand = os.path.normpath(os.path.join(walk_root, safe_id))
|
path = os.path.join(root, file_id)
|
||||||
try:
|
if not _path_inside_upload_dir(path):
|
||||||
canonical_cand = confine(root, cand, allow_root=False)
|
|
||||||
except (PathEscape, ValueError):
|
|
||||||
raise HTTPException(403, "Access denied")
|
raise HTTPException(403, "Access denied")
|
||||||
if os.path.isfile(canonical_cand):
|
if os.path.isfile(path):
|
||||||
return canonical_cand
|
return path
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
|
|
||||||
raise HTTPException(404, "File not found")
|
raise HTTPException(404, "File not found")
|
||||||
@@ -453,79 +435,52 @@ def setup_upload_routes(upload_handler):
|
|||||||
logger.error(f"Failed to get upload stats: {e}")
|
logger.error(f"Failed to get upload stats: {e}")
|
||||||
raise HTTPException(500, "Failed to get upload statistics")
|
raise HTTPException(500, "Failed to get upload statistics")
|
||||||
|
|
||||||
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
|
@router.get("/{file_id}")
|
||||||
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
||||||
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
|
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
|
||||||
a small cached JPEG thumbnail for images (used by chat attachment previews)
|
JPEG thumbnail for images (used by chat attachment previews) so the
|
||||||
so the client isn't downloading the full-resolution photo just to show it tiny."""
|
client isn't downloading the full-resolution photo just to show it tiny."""
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
safe_file_id = secure_filename(os.path.basename(file_id))
|
|
||||||
import mimetypes as _mt
|
import mimetypes as _mt
|
||||||
# Look up original filename and owner from uploads.json
|
# Look up original filename and owner from uploads.json
|
||||||
original_name = safe_file_id
|
original_name = file_id
|
||||||
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
|
||||||
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
# back to the .bak sibling, then to {}), so a truncated DB degrades to
|
||||||
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
|
||||||
db = upload_handler._load_upload_index()
|
db = upload_handler._load_upload_index()
|
||||||
info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
|
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||||
if info:
|
if info:
|
||||||
original_name = info.get("name", safe_file_id)
|
original_name = info.get("name", file_id)
|
||||||
file_owner = info.get("owner") if info else None
|
file_owner = info.get("owner") if info else None
|
||||||
_verify_upload_access(request, file_owner)
|
_verify_upload_access(request, file_owner)
|
||||||
upload_root = _upload_root()
|
path = _resolve_upload_path(file_id)
|
||||||
path = _resolve_upload_path(safe_file_id)
|
|
||||||
try:
|
|
||||||
path = confine(upload_root, path, allow_root=False)
|
|
||||||
except (PathEscape, ValueError):
|
|
||||||
raise HTTPException(403, "Access denied")
|
|
||||||
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
|
|
||||||
raise HTTPException(403, "Access denied")
|
|
||||||
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
# Downscaled thumbnail for image previews — generated once and cached.
|
# Downscaled thumbnail for image previews — generated once and cached.
|
||||||
# Skip generation during HEAD probe to avoid loading/processing whole image.
|
|
||||||
if thumb and mime.startswith("image/"):
|
if thumb and mime.startswith("image/"):
|
||||||
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
|
|
||||||
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
|
|
||||||
try:
|
try:
|
||||||
thumb_path = confine(upload_root, thumb_path, allow_root=False)
|
from PIL import Image, ImageOps
|
||||||
except (PathEscape, ValueError):
|
thumb_dir = os.path.join(_upload_root(), ".thumbs")
|
||||||
raise HTTPException(403, "Access denied")
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
|
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
|
||||||
raise HTTPException(403, "Access denied")
|
if (not os.path.exists(thumb_path)
|
||||||
if request.method == "HEAD":
|
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
||||||
if (os.path.isfile(thumb_path)
|
im = Image.open(path)
|
||||||
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
|
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
||||||
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
# the pixel data. Browsers honour that on the original via
|
||||||
# GET may generate a JPEG or fall back to the original on failure.
|
# image-orientation:from-image, but PIL strips EXIF when it
|
||||||
# Neither representation is known without decoding the image. HEAD
|
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
||||||
# must not advertise the original/stale file's headers as a thumb.
|
# the rotation into the pixels before thumbnailing.
|
||||||
from fastapi.responses import Response
|
im = ImageOps.exif_transpose(im)
|
||||||
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
|
im.thumbnail((320, 320))
|
||||||
del response.headers["content-length"]
|
if im.mode not in ("RGB", "L"):
|
||||||
return response
|
im = im.convert("RGB")
|
||||||
else:
|
im.save(thumb_path, "JPEG", quality=80)
|
||||||
try:
|
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
||||||
from PIL import Image, ImageOps
|
except Exception as e:
|
||||||
os.makedirs(thumb_dir, exist_ok=True)
|
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
||||||
if (not os.path.exists(thumb_path)
|
# Fall through to the full image.
|
||||||
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
|
|
||||||
im = Image.open(path)
|
|
||||||
# iPhone / camera JPEGs encode rotation in EXIF rather than
|
|
||||||
# the pixel data. Browsers honour that on the original via
|
|
||||||
# image-orientation:from-image, but PIL strips EXIF when it
|
|
||||||
# saves the JPEG thumb, leaving the pixels sideways. Bake
|
|
||||||
# the rotation into the pixels before thumbnailing.
|
|
||||||
im = ImageOps.exif_transpose(im)
|
|
||||||
im.thumbnail((320, 320))
|
|
||||||
if im.mode not in ("RGB", "L"):
|
|
||||||
im = im.convert("RGB")
|
|
||||||
im.save(thumb_path, "JPEG", quality=80)
|
|
||||||
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
|
|
||||||
# Fall through to the full image.
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path,
|
path,
|
||||||
media_type=mime,
|
media_type=mime,
|
||||||
@@ -541,10 +496,9 @@ def setup_upload_routes(upload_handler):
|
|||||||
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||||
|
|
||||||
def _vision_cache_path(file_id: str) -> str:
|
def _vision_cache_path(file_id: str) -> str:
|
||||||
safe_id = secure_filename(os.path.basename(file_id))
|
cache_dir = os.path.join(_upload_root(), ".vision")
|
||||||
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
|
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
|
return os.path.join(cache_dir, file_id + ".txt")
|
||||||
|
|
||||||
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
|
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
|
||||||
"""Copy upload OCR/vision text onto the promoted gallery image row."""
|
"""Copy upload OCR/vision text onto the promoted gallery image row."""
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
"""Fail image builds that select an unfixed Debian native Brotli decoder.
|
|
||||||
|
|
||||||
Debian Node links libbrotlidec rather than bundling upstream Node's decoder.
|
|
||||||
Use Debian's backport revision, not BrotliDecoderVersion() or Python wheel
|
|
||||||
metadata, to establish the package floor for CVE-2026-111497:
|
|
||||||
https://security-tracker.debian.org/tracker/CVE-2026-111497
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import ctypes
|
|
||||||
from pathlib import Path
|
|
||||||
import platform
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
DEBIAN_FIXED = {"12": "1.0.9-2+deb12u1", "13": "1.1.0-2+deb13u1"}
|
|
||||||
DECODER_SONAME = "libbrotlidec.so.1"
|
|
||||||
|
|
||||||
|
|
||||||
def command(*args):
|
|
||||||
return subprocess.check_output(args, text=True).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def check_package():
|
|
||||||
release = platform.freedesktop_os_release()
|
|
||||||
floor = DEBIAN_FIXED.get(release.get("VERSION_ID"))
|
|
||||||
if release.get("ID") != "debian" or floor is None:
|
|
||||||
raise ValueError("Brotli package floor is not defined for this distribution")
|
|
||||||
record = command(
|
|
||||||
"dpkg-query", "-W", "-f=${Status}\n${Version}", "libbrotli1"
|
|
||||||
).splitlines()
|
|
||||||
if len(record) != 2 or any(not value.strip() for value in record):
|
|
||||||
raise ValueError("invalid libbrotli1 package record: expected status and version")
|
|
||||||
status, version = record
|
|
||||||
if status != "install ok installed":
|
|
||||||
raise ValueError(f"libbrotli1 is not fully installed (status: {status})")
|
|
||||||
if subprocess.run(
|
|
||||||
["dpkg", "--compare-versions", version, "ge", floor], check=False
|
|
||||||
).returncode != 0:
|
|
||||||
raise ValueError(f"libbrotli1 {version} does not meet Debian's fixed floor {floor}")
|
|
||||||
owned = {
|
|
||||||
Path(line).resolve()
|
|
||||||
for line in command("dpkg-query", "-L", "libbrotli1").splitlines()
|
|
||||||
if Path(line).name == DECODER_SONAME
|
|
||||||
}
|
|
||||||
if not owned:
|
|
||||||
raise ValueError("libbrotli1 has no packaged native decoder")
|
|
||||||
return version, owned
|
|
||||||
|
|
||||||
|
|
||||||
def loaded_path(library):
|
|
||||||
# Inspect the library actually selected by the dynamic loader. An installed
|
|
||||||
# fixed package alone would not catch LD_LIBRARY_PATH or /usr/local shadows.
|
|
||||||
class DlInfo(ctypes.Structure):
|
|
||||||
_fields_ = [("filename", ctypes.c_char_p), ("base", ctypes.c_void_p),
|
|
||||||
("symbol", ctypes.c_char_p), ("address", ctypes.c_void_p)]
|
|
||||||
|
|
||||||
dladdr = ctypes.CDLL(None).dladdr
|
|
||||||
dladdr.argtypes = [ctypes.c_void_p, ctypes.POINTER(DlInfo)]
|
|
||||||
dladdr.restype = ctypes.c_int
|
|
||||||
info = DlInfo()
|
|
||||||
if not dladdr(ctypes.cast(library.BrotliDecoderDecompress, ctypes.c_void_p), ctypes.byref(info)):
|
|
||||||
raise ValueError("cannot identify the loaded Brotli decoder")
|
|
||||||
return Path(info.filename.decode()).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def check_decoder(library):
|
|
||||||
# The fix adds error -17. Older decoders return INVALID here, including
|
|
||||||
# upstream 1.1.0 and 1.2.0. Fixed Debian 12 and 13 backports expose the same
|
|
||||||
# marker with different error-string prefixes.
|
|
||||||
# No malformed attacker-controlled stream is needed for this build check.
|
|
||||||
library.BrotliDecoderErrorString.argtypes = [ctypes.c_int]
|
|
||||||
library.BrotliDecoderErrorString.restype = ctypes.c_char_p
|
|
||||||
if library.BrotliDecoderErrorString(-17) not in (b"BLOCK_SWITCH", b"_ERROR_FORMAT_BLOCK_SWITCH"):
|
|
||||||
raise ValueError("native decoder lacks the CVE-2026-111497 BLOCK_SWITCH fix marker")
|
|
||||||
|
|
||||||
library.BrotliDecoderDecompress.argtypes = [
|
|
||||||
ctypes.c_size_t, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p
|
|
||||||
]
|
|
||||||
library.BrotliDecoderDecompress.restype = ctypes.c_int
|
|
||||||
# A small, valid uncompressed Brotli stream must still decode correctly.
|
|
||||||
encoded = bytes.fromhex("0b058068656c6c6f20776f726c6403")
|
|
||||||
output = ctypes.create_string_buffer(11)
|
|
||||||
size = ctypes.c_size_t(len(output))
|
|
||||||
result = library.BrotliDecoderDecompress(len(encoded), encoded, ctypes.byref(size), output)
|
|
||||||
if result != 1 or size.value != 11 or output.raw != b"hello world":
|
|
||||||
raise ValueError("native Brotli decoder failed the valid-stream control")
|
|
||||||
|
|
||||||
|
|
||||||
def check_node(node, decoder):
|
|
||||||
# ldd inspects the distro executable and its libnode dependency without
|
|
||||||
# executing JavaScript or relying on process.versions.brotli.
|
|
||||||
linked = command("ldd", str(node))
|
|
||||||
match = re.search(r"libbrotlidec\.so\.1\s+=>\s+(/\S+)", linked)
|
|
||||||
if not match or Path(match[1]).resolve() != decoder:
|
|
||||||
raise ValueError(f"{node} does not select the checked Debian Brotli decoder")
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--node", type=Path, help="also verify this distro Node executable's decoder")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
try:
|
|
||||||
version, owned = check_package()
|
|
||||||
library = ctypes.CDLL(DECODER_SONAME)
|
|
||||||
decoder = loaded_path(library)
|
|
||||||
if decoder not in owned:
|
|
||||||
raise ValueError(f"selected decoder is outside libbrotli1: {decoder}")
|
|
||||||
check_decoder(library)
|
|
||||||
if args.node:
|
|
||||||
check_node(args.node, decoder)
|
|
||||||
except (ValueError, OSError, AttributeError, subprocess.CalledProcessError) as exc:
|
|
||||||
print(f"Brotli acceptance failed: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(f"Brotli accepted: Debian libbrotli1 {version}, decoder {decoder}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -951,12 +951,14 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
|||||||
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
|
||||||
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
|
||||||
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
|
||||||
|
"ODYSSEUS_SPEC_DRIVER_TOPK": ("Agent loop and tool execution", INTERNAL, "Number of router-selected tool candidates exposed in a driver round."),
|
||||||
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
|
||||||
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
|
||||||
|
"ODYSSEUS_SPEC_SUBSET_OF_BASE": ("Agent loop and tool execution", INTERNAL, "Limit Spec tool selections to the existing request inventory by default."),
|
||||||
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
|
||||||
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
|
||||||
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Sanitize Playwright trace archives to strip sensitive credentials and tokens.
|
|
||||||
|
|
||||||
Structured, format-aware fail-closed security:
|
|
||||||
Replaces naive regex-based replacement with structured JSON / HAR parsing,
|
|
||||||
recursive key/value redaction, URL credential scrubbing, DOM snapshot sanitization,
|
|
||||||
safe source code masking, and strict fail-closed purge on corruption or error.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import urllib.parse
|
|
||||||
import zipfile
|
|
||||||
from typing import Any, Dict, List, Set
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
BINARY_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.ico')
|
|
||||||
|
|
||||||
SENSITIVE_KEY_PATTERN = re.compile(
|
|
||||||
r'(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)'
|
|
||||||
)
|
|
||||||
|
|
||||||
SENSITIVE_PARAM_NAMES = frozenset({
|
|
||||||
"token", "session", "auth", "password", "passwd", "key", "secret", "api_key",
|
|
||||||
"apikey", "access_token", "refresh_token", "jwt", "sid", "code", "sig",
|
|
||||||
"signature", "client_secret", "credential"
|
|
||||||
})
|
|
||||||
|
|
||||||
SENSITIVE_HEADER_NAMES = frozenset({
|
|
||||||
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
|
|
||||||
"api-key", "x-auth-token", "x-access-token"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_url(url: str) -> str:
|
|
||||||
"""Sanitize query parameters and authority credentials in URLs."""
|
|
||||||
if not isinstance(url, str) or not url.strip():
|
|
||||||
return url
|
|
||||||
try:
|
|
||||||
parts = urllib.parse.urlsplit(url)
|
|
||||||
except Exception:
|
|
||||||
return url
|
|
||||||
|
|
||||||
netloc = parts.netloc
|
|
||||||
if "@" in netloc:
|
|
||||||
userinfo, host = netloc.split("@", 1)
|
|
||||||
if ":" in userinfo:
|
|
||||||
user, _ = userinfo.split(":", 1)
|
|
||||||
netloc = f"{user}:[REDACTED]@{host}"
|
|
||||||
else:
|
|
||||||
netloc = f"[REDACTED]@{host}"
|
|
||||||
|
|
||||||
query = parts.query
|
|
||||||
if query:
|
|
||||||
q_pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
|
|
||||||
sanitized_pairs = []
|
|
||||||
for k, v in q_pairs:
|
|
||||||
k_lower = k.lower().replace("-", "_")
|
|
||||||
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
|
||||||
sanitized_pairs.append((k, "[REDACTED]"))
|
|
||||||
else:
|
|
||||||
sanitized_pairs.append((k, v))
|
|
||||||
query = urllib.parse.urlencode(sanitized_pairs)
|
|
||||||
|
|
||||||
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_body_text(text: str) -> str:
|
|
||||||
"""Sanitize request bodies formatted as JSON or URL-encoded form data."""
|
|
||||||
if not isinstance(text, str) or not text.strip():
|
|
||||||
return text
|
|
||||||
|
|
||||||
# 1. Try JSON
|
|
||||||
trimmed = text.strip()
|
|
||||||
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(trimmed)
|
|
||||||
sanitized = sanitize_structured_obj(parsed)
|
|
||||||
return json.dumps(sanitized, ensure_ascii=False)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 2. Try URL-encoded form data
|
|
||||||
if "=" in trimmed and not trimmed.startswith("<"):
|
|
||||||
try:
|
|
||||||
pairs = urllib.parse.parse_qsl(trimmed, keep_blank_values=True)
|
|
||||||
if pairs and any(k.lower() in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k) for k, _ in pairs):
|
|
||||||
out_pairs = []
|
|
||||||
for k, v in pairs:
|
|
||||||
k_lower = k.lower().replace("-", "_")
|
|
||||||
if k_lower in SENSITIVE_PARAM_NAMES or SENSITIVE_KEY_PATTERN.search(k_lower):
|
|
||||||
out_pairs.append((k, "[REDACTED]"))
|
|
||||||
else:
|
|
||||||
out_pairs.append((k, v))
|
|
||||||
return urllib.parse.urlencode(out_pairs)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_structured_obj(obj: Any) -> Any:
|
|
||||||
"""Recursively sanitize Python objects decoded from JSON or HAR payloads."""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
new_dict: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
# Check for header entry with inverted or standard keys: {"name": ..., "value": ...}
|
|
||||||
name_val = obj.get("name")
|
|
||||||
val_val = obj.get("value")
|
|
||||||
if isinstance(name_val, str) and isinstance(val_val, str):
|
|
||||||
name_lower = name_val.lower().strip()
|
|
||||||
if name_lower in SENSITIVE_HEADER_NAMES or SENSITIVE_KEY_PATTERN.search(name_lower):
|
|
||||||
for k, v in obj.items():
|
|
||||||
if k == "value":
|
|
||||||
new_dict[k] = "[REDACTED]"
|
|
||||||
else:
|
|
||||||
new_dict[k] = sanitize_structured_obj(v)
|
|
||||||
return new_dict
|
|
||||||
|
|
||||||
# Process standard dictionary keys
|
|
||||||
for k, v in obj.items():
|
|
||||||
k_lower = str(k).lower().replace("-", "_")
|
|
||||||
|
|
||||||
# Action parameters in Playwright traces (e.g. page.fill, page.type)
|
|
||||||
if k == "params" and isinstance(v, dict):
|
|
||||||
sanitized_params = sanitize_structured_obj(v)
|
|
||||||
# If this action involves fill, type, or sensitive inputs, redact value/text
|
|
||||||
if "value" in sanitized_params:
|
|
||||||
sanitized_params["value"] = "[REDACTED]"
|
|
||||||
if "text" in sanitized_params:
|
|
||||||
sanitized_params["text"] = "[REDACTED]"
|
|
||||||
new_dict[k] = sanitized_params
|
|
||||||
continue
|
|
||||||
|
|
||||||
# DOM snapshot nodes
|
|
||||||
if k == "attributes" and isinstance(v, (dict, list)):
|
|
||||||
if isinstance(v, dict):
|
|
||||||
attr_dict = dict(v)
|
|
||||||
type_val = str(attr_dict.get("type") or "").lower()
|
|
||||||
if type_val == "password" or "password" in str(attr_dict.get("name") or "").lower():
|
|
||||||
if "value" in attr_dict:
|
|
||||||
attr_dict["value"] = "[REDACTED]"
|
|
||||||
new_dict[k] = attr_dict
|
|
||||||
continue
|
|
||||||
elif isinstance(v, list):
|
|
||||||
# List of attribute strings or pairs
|
|
||||||
new_list = []
|
|
||||||
is_pw = any("password" in str(x).lower() for x in v)
|
|
||||||
for x in v:
|
|
||||||
if is_pw and isinstance(x, str) and not x.startswith("type="):
|
|
||||||
new_list.append("[REDACTED]")
|
|
||||||
else:
|
|
||||||
new_list.append(sanitize_structured_obj(x))
|
|
||||||
new_dict[k] = new_list
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Request postData / text
|
|
||||||
if k in ("postData", "postDataText") and isinstance(v, str):
|
|
||||||
new_dict[k] = sanitize_body_text(v)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# URL fields
|
|
||||||
if k == "url" and isinstance(v, str):
|
|
||||||
new_dict[k] = sanitize_url(v)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Cookie arrays
|
|
||||||
if k == "cookies" and isinstance(v, list):
|
|
||||||
sanitized_cookies = []
|
|
||||||
for c in v:
|
|
||||||
if isinstance(c, dict):
|
|
||||||
c_copy = dict(c)
|
|
||||||
if "value" in c_copy:
|
|
||||||
c_copy["value"] = "[REDACTED]"
|
|
||||||
sanitized_cookies.append(c_copy)
|
|
||||||
else:
|
|
||||||
sanitized_cookies.append(sanitize_structured_obj(c))
|
|
||||||
new_dict[k] = sanitized_cookies
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Direct sensitive key matches
|
|
||||||
if SENSITIVE_KEY_PATTERN.search(k_lower) and isinstance(v, str):
|
|
||||||
new_dict[k] = "[REDACTED]"
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_dict[k] = sanitize_structured_obj(v)
|
|
||||||
|
|
||||||
return new_dict
|
|
||||||
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
return [sanitize_structured_obj(item) for item in obj]
|
|
||||||
|
|
||||||
elif isinstance(obj, str):
|
|
||||||
# Check for inline credentials/tokens
|
|
||||||
if obj.startswith("Bearer ") or obj.startswith("bearer "):
|
|
||||||
return "Bearer [REDACTED]"
|
|
||||||
if obj.startswith("Basic ") or obj.startswith("basic "):
|
|
||||||
return "Basic [REDACTED]"
|
|
||||||
return obj
|
|
||||||
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_jsonl_content(data: bytes) -> bytes:
|
|
||||||
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
|
|
||||||
lines = data.split(b"\n")
|
|
||||||
out_lines = []
|
|
||||||
for line in lines:
|
|
||||||
if not line.strip():
|
|
||||||
out_lines.append(line)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
parsed = json.loads(line.decode("utf-8"))
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
# Not valid JSON, keep as-is
|
|
||||||
out_lines.append(line)
|
|
||||||
continue
|
|
||||||
sanitized = sanitize_structured_obj(parsed)
|
|
||||||
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
|
||||||
return b"\n".join(out_lines)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_trace_archive(zp: str) -> bool:
|
|
||||||
"""Sanitize a single zip archive in a strict fail-closed manner.
|
|
||||||
|
|
||||||
If the archive is malformed, partial, unreadable, or encounters an exception
|
|
||||||
during processing, the archive is permanently deleted so that raw traces
|
|
||||||
cannot leak.
|
|
||||||
Returns True if successfully sanitized, False if deleted due to error.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(zp):
|
|
||||||
return False
|
|
||||||
|
|
||||||
temp_zp = zp + ".tmp"
|
|
||||||
try:
|
|
||||||
if os.path.getsize(zp) == 0:
|
|
||||||
raise zipfile.BadZipFile("Empty archive")
|
|
||||||
|
|
||||||
with zipfile.ZipFile(zp, "r") as zin:
|
|
||||||
infolist = zin.infolist()
|
|
||||||
if not infolist:
|
|
||||||
raise zipfile.BadZipFile("Archive has no entries")
|
|
||||||
|
|
||||||
corrupt_file = zin.testzip()
|
|
||||||
if corrupt_file is not None:
|
|
||||||
raise zipfile.BadZipFile(f"Corrupt entry detected: {corrupt_file}")
|
|
||||||
|
|
||||||
with zipfile.ZipFile(temp_zp, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
|
||||||
for item in infolist:
|
|
||||||
filename_lower = item.filename.lower()
|
|
||||||
|
|
||||||
# 1. Binary image screenshots/diagnostics: preserve as-is
|
|
||||||
if filename_lower.endswith(BINARY_IMAGE_EXTENSIONS):
|
|
||||||
content = zin.read(item.filename)
|
|
||||||
zout.writestr(item, content)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 2. Embedded test source code (e.g. resources/src@... or .js/.ts):
|
|
||||||
# Mask safely without syntax corruption!
|
|
||||||
if "src@" in filename_lower or filename_lower.endswith((".js", ".ts", ".mjs", ".py")):
|
|
||||||
safe_placeholder = b"// [SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]\n"
|
|
||||||
zout.writestr(item, safe_placeholder)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 3. JSON Lines and structured JSON/HAR traces
|
|
||||||
content = zin.read(item.filename)
|
|
||||||
if filename_lower.endswith((".trace", ".network", ".stacks")):
|
|
||||||
sanitized_content = sanitize_jsonl_content(content)
|
|
||||||
zout.writestr(item, sanitized_content)
|
|
||||||
elif filename_lower.endswith((".json", ".har")):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(content.decode("utf-8"))
|
|
||||||
sanitized = sanitize_structured_obj(parsed)
|
|
||||||
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
# If invalid JSON, treat as text line-by-line
|
|
||||||
zout.writestr(item, sanitize_jsonl_content(content))
|
|
||||||
else:
|
|
||||||
# Unknown binary or text member: exclude to fail closed
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Atomic replace
|
|
||||||
os.replace(temp_zp, zp)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Sanitizing trace %s failed (%s); removing fail-closed", zp, exc)
|
|
||||||
for path_to_remove in (temp_zp, zp):
|
|
||||||
if os.path.exists(path_to_remove):
|
|
||||||
try:
|
|
||||||
os.remove(path_to_remove)
|
|
||||||
except OSError as remove_err:
|
|
||||||
logger.error("Failed to delete unredacted trace %s: %s", path_to_remove, remove_err)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_directory(results_dir: str) -> int:
|
|
||||||
"""Walk results_dir and sanitize every .zip archive fail-closed.
|
|
||||||
|
|
||||||
Returns the number of archives processed. Exits with 1 if any archive fails.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(results_dir):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
all_ok = True
|
|
||||||
for root, _, files in os.walk(results_dir):
|
|
||||||
for f in files:
|
|
||||||
if f.endswith(".zip"):
|
|
||||||
zp = os.path.join(root, f)
|
|
||||||
ok = sanitize_trace_archive(zp)
|
|
||||||
if not ok:
|
|
||||||
all_ok = False
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
if not all_ok:
|
|
||||||
sys.exit(1)
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print(f"Usage: {sys.argv[0]} <results_directory>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
target_dir = sys.argv[1]
|
|
||||||
sanitize_directory(target_dir)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -628,14 +628,6 @@ async def extract_and_store(
|
|||||||
if not fact_text or len(fact_text) < 5:
|
if not fact_text or len(fact_text) < 5:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# A fuller owner-specific name must not rewrite a shared legacy
|
|
||||||
# entry, but that less-specific entry must not suppress it either.
|
|
||||||
legacy_refinements = {
|
|
||||||
id(entry) for entry in existing
|
|
||||||
if _owner and entry.get("owner") is None
|
|
||||||
and _name_enrichment_target(fact_text, [entry]) is entry
|
|
||||||
}
|
|
||||||
|
|
||||||
# Dedup: check vector similarity first (fast), then exact text match.
|
# Dedup: check vector similarity first (fast), then exact text match.
|
||||||
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
|
||||||
# remote endpoint down) must not abort the whole batch — fall through
|
# remote endpoint down) must not abort the whole batch — fall through
|
||||||
@@ -657,25 +649,18 @@ async def extract_and_store(
|
|||||||
# silently dropped. Mirror the owner predicate used by the
|
# silently dropped. Mirror the owner predicate used by the
|
||||||
# text dedup below; cross-tenant/stale matches fall through.
|
# text dedup below; cross-tenant/stale matches fall through.
|
||||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
||||||
if (_match is not None and id(_match) not in legacy_refinements
|
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
|
||||||
and (_match.get("owner") == _owner or _match.get("owner") is None)):
|
|
||||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Text dedup fallback: exact match + fuzzy similarity
|
# Text dedup fallback: exact match + fuzzy similarity
|
||||||
user_existing = [e for e in existing
|
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
||||||
if (e.get("owner") == _owner or e.get("owner") is None)
|
|
||||||
and id(e) not in legacy_refinements] if _owner else existing
|
|
||||||
|
|
||||||
# Name enrichment: if an existing name fact is a strict prefix of
|
# Name enrichment: if an existing name fact is a strict prefix of
|
||||||
# this more complete one, upgrade it in place instead of adding a
|
# this more complete one, upgrade it in place instead of adding a
|
||||||
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
|
||||||
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
|
||||||
# Legacy unowned entries may be visible to multiple owners; never
|
_enrich = _name_enrichment_target(fact_text, user_existing)
|
||||||
# rewrite one with a private fact from the current owner.
|
|
||||||
_enrich = _name_enrichment_target(
|
|
||||||
fact_text, [e for e in user_existing if e.get("owner") == _owner]
|
|
||||||
)
|
|
||||||
if _enrich is not None:
|
if _enrich is not None:
|
||||||
_enrich["text"] = fact_text
|
_enrich["text"] = fact_text
|
||||||
_enrich["timestamp"] = int(time.time())
|
_enrich["timestamp"] = int(time.time())
|
||||||
|
|||||||
+140
-155
@@ -2834,7 +2834,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
|
|||||||
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
|
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "list_tools"})
|
return "manage_settings", json.dumps({"action": "list_tools"})
|
||||||
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "list_tools"})
|
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
|
||||||
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
|
||||||
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
|
||||||
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
|
||||||
@@ -8057,7 +8057,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
|
|||||||
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
|
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
|
||||||
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
|
||||||
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
|
||||||
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
|
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
|
||||||
"manage_notes": """\
|
"manage_notes": """\
|
||||||
```manage_notes
|
```manage_notes
|
||||||
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
|
||||||
@@ -21061,103 +21061,6 @@ from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_author
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
|
|
||||||
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
|
||||||
disabled = set(disabled_tools or ())
|
|
||||||
from src.spec_router import latest_decision
|
|
||||||
_spec_decision = latest_decision() or {}
|
|
||||||
_spec_choice = _spec_decision.get("choice")
|
|
||||||
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
|
||||||
and decided and _spec_choice == "answer_directly")
|
|
||||||
try:
|
|
||||||
_mgr = get_mcp_manager()
|
|
||||||
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
|
||||||
except Exception:
|
|
||||||
mcp_schemas = []
|
|
||||||
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
|
||||||
if _spec_choice == "ask_user":
|
|
||||||
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
|
||||||
# can act when the detail is there or ask when it is not.
|
|
||||||
_top = _spec_decision.get("top") or []
|
|
||||||
if _top:
|
|
||||||
picks = set(picks) | {_top[0][0]}
|
|
||||||
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
|
||||||
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
|
||||||
base_offered = set(turn_contract.offered)
|
|
||||||
# The router sees the full catalog, while this request's policy may
|
|
||||||
# deny a tool (for example, a browser turn with Web access switched
|
|
||||||
# off). Preserve that conflict as an unavailable contract. Keeping the
|
|
||||||
# old empty menu instead lets the model invent a tool result in prose.
|
|
||||||
_top = _spec_decision.get("top") or []
|
|
||||||
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
|
||||||
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
|
||||||
_primary = f"mcp__email__{_primary}"
|
|
||||||
# A policy denial is surfaced even if the router chose outside the
|
|
||||||
# base inventory; it never grants access to that tool.
|
|
||||||
def _denied(name):
|
|
||||||
return (name in disabled
|
|
||||||
or (tool_policy is not None and (
|
|
||||||
tool_policy.blocks(name)
|
|
||||||
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
|
||||||
_unavailable_pick = ""
|
|
||||||
if _primary in picks and _denied(_primary):
|
|
||||||
_unavailable_pick = _primary
|
|
||||||
elif picks and all(_denied(name) for name in picks):
|
|
||||||
_unavailable_pick = sorted(picks)[0]
|
|
||||||
if _unavailable_pick:
|
|
||||||
blocked = replace(
|
|
||||||
turn_contract, required=frozenset(), offered=frozenset(),
|
|
||||||
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
|
||||||
required_read_operation=None, routing_experiment="spec-policy-denied",
|
|
||||||
)
|
|
||||||
_spec_active_contract.set(blocked)
|
|
||||||
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
|
||||||
return blocked
|
|
||||||
want = set(picks) - disabled
|
|
||||||
# Always-on basics only when the base turn offers them, so the Spec
|
|
||||||
# arm never offers a tool the base arm would not.
|
|
||||||
if not _spec_direct:
|
|
||||||
want |= {"ask_user", "update_plan"} & base_offered
|
|
||||||
if not (strict and decided):
|
|
||||||
want |= base_offered
|
|
||||||
want &= base_offered
|
|
||||||
if not want:
|
|
||||||
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
|
||||||
sorted(picks), sorted(base_offered))
|
|
||||||
return turn_contract
|
|
||||||
offer = want & base_offered & set(turn_contract.executable)
|
|
||||||
# A router may narrow the menu only when every existing obligation
|
|
||||||
# remains satisfiable. It cannot redefine the user's requested work.
|
|
||||||
required_read = getattr(turn_contract, "required_read_operation", None)
|
|
||||||
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
|
|
||||||
or (required_read is not None and required_read.tool not in offer)):
|
|
||||||
return turn_contract
|
|
||||||
by_name = {}
|
|
||||||
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
|
||||||
nm = (sc.get("function") or {}).get("name")
|
|
||||||
if nm in offer and nm not in by_name:
|
|
||||||
by_name[nm] = sc
|
|
||||||
for sc in turn_contract.schemas():
|
|
||||||
nm = (sc.get("function") or {}).get("name")
|
|
||||||
if nm in offer:
|
|
||||||
by_name[nm] = sc
|
|
||||||
if not by_name and not _spec_direct:
|
|
||||||
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
|
||||||
return turn_contract
|
|
||||||
# Required tools and the required read carry over unchanged: harness
|
|
||||||
# completion logic keys on them (e.g. a calendar list is a read, not an
|
|
||||||
# effect that may end with "Done."). If one lost its schema, contract
|
|
||||||
# validation fails and the original contract is retained below.
|
|
||||||
new = replace(
|
|
||||||
turn_contract, offered=frozenset(by_name),
|
|
||||||
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
|
||||||
routing_experiment="spec-router")
|
|
||||||
_spec_active_contract.set(new)
|
|
||||||
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
|
||||||
sorted(by_name), sorted(turn_contract.offered), decided)
|
|
||||||
return new
|
|
||||||
|
|
||||||
|
|
||||||
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
tool_policy=None):
|
tool_policy=None):
|
||||||
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
|
||||||
@@ -21166,17 +21069,10 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
|||||||
Runs before any runtime dispatch so the compact preview runtime and the
|
Runs before any runtime dispatch so the compact preview runtime and the
|
||||||
main loop both serve the router's set; only the offered tools change.
|
main loop both serve the router's set; only the offered tools change.
|
||||||
"""
|
"""
|
||||||
# Contexts can be inherited by a child task; a turn must never inherit a
|
|
||||||
# previous turn's decision when routing is disabled or unavailable.
|
|
||||||
from src import spec_router as _router_context
|
|
||||||
if hasattr(_router_context, "clear_decision"):
|
|
||||||
_router_context.clear_decision()
|
|
||||||
if (turn_contract is None
|
if (turn_contract is None
|
||||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
||||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
||||||
return turn_contract
|
return turn_contract
|
||||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
|
||||||
return turn_contract
|
|
||||||
try:
|
try:
|
||||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
||||||
return turn_contract
|
return turn_contract
|
||||||
@@ -21184,38 +21080,105 @@ def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url,
|
|||||||
return turn_contract
|
return turn_contract
|
||||||
try:
|
try:
|
||||||
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
from src.spec_router import spec_route_tools as _spec_route_tools_early
|
||||||
|
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||||
|
disabled = set(disabled_tools or ())
|
||||||
picks, decided = _spec_route_tools_early(
|
picks, decided = _spec_route_tools_early(
|
||||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
||||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
from src.spec_router import LAST_DECISIONS as _spec_decisions
|
||||||
except Exception as exc:
|
_spec_choice = (_spec_decisions[-1] if _spec_decisions else {}).get("choice")
|
||||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
return turn_contract
|
and decided and _spec_choice == "answer_directly")
|
||||||
|
try:
|
||||||
|
_mgr = get_mcp_manager()
|
||||||
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
|
||||||
tool_policy=None):
|
except Exception:
|
||||||
"""Asynchronous non-blocking turn contract routing."""
|
mcp_schemas = []
|
||||||
from src import spec_router as _router_context
|
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
|
||||||
if hasattr(_router_context, "clear_decision"):
|
if _spec_choice == "ask_user":
|
||||||
_router_context.clear_decision()
|
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
|
||||||
if (turn_contract is None
|
# can act when the detail is there or ask when it is not.
|
||||||
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
|
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||||
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
|
if _top:
|
||||||
return turn_contract
|
picks = set(picks) | {_top[0][0]}
|
||||||
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
|
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
|
||||||
return turn_contract
|
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
|
||||||
try:
|
base_offered = set(turn_contract.offered)
|
||||||
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
|
subset_of_base = os.getenv("ODYSSEUS_SPEC_SUBSET_OF_BASE", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
# The router sees the full catalog, while this request's policy may
|
||||||
|
# deny a tool (for example, a browser turn with Web access switched
|
||||||
|
# off). Preserve that conflict as an unavailable contract. Keeping the
|
||||||
|
# old empty menu instead lets the model invent a tool result in prose.
|
||||||
|
_top = ((_spec_decisions[-1] if _spec_decisions else {}).get("top") or [])
|
||||||
|
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
|
||||||
|
if _primary and f"mcp__email__{_primary}" in mcp_names:
|
||||||
|
_primary = f"mcp__email__{_primary}"
|
||||||
|
# `executable` belongs to the base contract. In subset=0, Spec may
|
||||||
|
# legitimately add a policy-permitted tool that the base inventory did
|
||||||
|
# not include. Only an explicit request-policy denial blocks the pick.
|
||||||
|
def _denied(name):
|
||||||
|
return (name in disabled
|
||||||
|
or (tool_policy is not None and (
|
||||||
|
tool_policy.blocks(name)
|
||||||
|
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
|
||||||
|
_unavailable_pick = ""
|
||||||
|
if _primary in picks and _denied(_primary):
|
||||||
|
_unavailable_pick = _primary
|
||||||
|
elif picks and all(_denied(name) for name in picks):
|
||||||
|
_unavailable_pick = sorted(picks)[0]
|
||||||
|
if _unavailable_pick:
|
||||||
|
blocked = replace(
|
||||||
|
turn_contract, required=frozenset(), offered=frozenset(),
|
||||||
|
unavailable=frozenset({_unavailable_pick}), schema_json=(),
|
||||||
|
required_read_operation=None, routing_experiment="spec-policy-denied",
|
||||||
|
)
|
||||||
|
_spec_active_contract.set(blocked)
|
||||||
|
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
|
||||||
|
return blocked
|
||||||
|
want = set(picks) - disabled
|
||||||
|
# Always-on basics only when the base turn offers them, so the Spec
|
||||||
|
# arm never offers a tool the base arm would not.
|
||||||
|
if not _spec_direct:
|
||||||
|
want |= {"ask_user", "update_plan"} & base_offered
|
||||||
|
if not (strict and decided):
|
||||||
|
want |= base_offered
|
||||||
|
if subset_of_base:
|
||||||
|
# A/B control: Spec offered set is a subset of the base offered set
|
||||||
|
# (measures narrowing only). Empty intersection keeps the base set.
|
||||||
|
want &= base_offered
|
||||||
|
if not want:
|
||||||
|
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
|
||||||
|
sorted(picks), sorted(base_offered))
|
||||||
|
return turn_contract
|
||||||
|
offer = want if strict else (want & set(turn_contract.executable))
|
||||||
|
by_name = {}
|
||||||
|
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer and nm not in by_name:
|
||||||
|
by_name[nm] = sc
|
||||||
|
for sc in turn_contract.schemas():
|
||||||
|
nm = (sc.get("function") or {}).get("name")
|
||||||
|
if nm in offer:
|
||||||
|
by_name[nm] = sc
|
||||||
|
if not by_name and not _spec_direct:
|
||||||
|
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
|
||||||
return turn_contract
|
return turn_contract
|
||||||
except Exception:
|
# Keep the contract's required tools and required read whenever they
|
||||||
return turn_contract
|
# are still offered: harness completion logic keys on them (e.g. a
|
||||||
try:
|
# calendar list is a read, not an effect that may end with "Done.").
|
||||||
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
|
_rro = getattr(turn_contract, "required_read_operation", None)
|
||||||
picks, decided = await _spec_route_tools_early_async(
|
_keep_rro = _rro if (_rro is not None and getattr(_rro, "tool", None) in by_name) else None
|
||||||
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
|
new = replace(
|
||||||
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
|
turn_contract, required=frozenset(n for n in (turn_contract.required or ()) if n in by_name),
|
||||||
|
offered=frozenset(by_name),
|
||||||
|
executable=frozenset(set(turn_contract.executable) | set(by_name)),
|
||||||
|
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
|
||||||
|
unavailable=(frozenset() if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" else turn_contract.unavailable), required_read_operation=_keep_rro, routing_experiment="spec-router")
|
||||||
|
_spec_active_contract.set(new)
|
||||||
|
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
|
||||||
|
sorted(by_name), sorted(turn_contract.offered), decided)
|
||||||
|
return new
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
|
logger.warning("[spec-harness] router contract replacement failed open: %s", exc)
|
||||||
return turn_contract
|
return turn_contract
|
||||||
|
|
||||||
|
|
||||||
@@ -21300,11 +21263,20 @@ async def stream_agent_loop(
|
|||||||
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
|
||||||
and not (tool_policy and tool_policy.block_all_tool_calls)
|
and not (tool_policy and tool_policy.block_all_tool_calls)
|
||||||
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
|
||||||
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
|
from src.spec_driver import stream_spec_turn
|
||||||
yield 'data: [DONE]\n\n'
|
async for chunk in stream_spec_turn(
|
||||||
|
endpoint_url=endpoint_url, model=model, messages=messages, headers=headers,
|
||||||
|
session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||||
|
tool_policy=tool_policy, workspace=workspace, active_document=active_document,
|
||||||
|
client_runtime_context=client_runtime_context,
|
||||||
|
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||||
|
max_tokens=max(1024, min(int(max_tokens or 1024), 4096)), max_rounds=8,
|
||||||
|
temperature=0.0, request_authority=request_authority,
|
||||||
|
):
|
||||||
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
turn_contract = await _spec_router_contract_async(
|
turn_contract = _spec_router_contract(
|
||||||
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
|
||||||
tool_policy=tool_policy)
|
tool_policy=tool_policy)
|
||||||
if (turn_contract is not None
|
if (turn_contract is not None
|
||||||
@@ -21317,20 +21289,6 @@ async def stream_agent_loop(
|
|||||||
yield 'data: [DONE]\n\n'
|
yield 'data: [DONE]\n\n'
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
|
||||||
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
|
||||||
except Exception:
|
|
||||||
_spec_turn = False
|
|
||||||
# Without a turn contract (scheduled, skill and monitor runs) no Spec
|
|
||||||
# routing applies; the turn runs on the compact surface as usual.
|
|
||||||
if _spec_turn and _blocks_before_inference(turn_contract):
|
|
||||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
|
||||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
|
||||||
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
|
|
||||||
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
|
|
||||||
yield 'data: [DONE]\n\n'
|
|
||||||
return
|
|
||||||
|
|
||||||
if is_compact_preview_contract(turn_contract):
|
if is_compact_preview_contract(turn_contract):
|
||||||
from src.clean_agent_preview import stream_preview
|
from src.clean_agent_preview import stream_preview
|
||||||
async for chunk in stream_preview(
|
async for chunk in stream_preview(
|
||||||
@@ -21356,9 +21314,26 @@ async def stream_agent_loop(
|
|||||||
yield chunk
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_spec_turn = False
|
||||||
|
try:
|
||||||
|
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
|
||||||
|
except Exception:
|
||||||
|
_spec_turn = False
|
||||||
|
if _spec_turn and turn_contract is not None and getattr(turn_contract, "routing_experiment", "") != "spec-router":
|
||||||
|
# Ajax Spec: the router, not the regex turn contract, decides the tools.
|
||||||
|
# Drop the contract here and in the dispatcher's context so neither the
|
||||||
|
# route build nor execute_tool_block enforces its offered set.
|
||||||
|
logger.info("[spec-harness] dropping turn contract on spec turn: offered=%s unavailable=%s",
|
||||||
|
sorted(turn_contract.offered), sorted(turn_contract.unavailable))
|
||||||
|
turn_contract = None
|
||||||
|
try:
|
||||||
|
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
|
||||||
|
_spec_active_contract.set(None)
|
||||||
|
except Exception as _exc:
|
||||||
|
logger.warning("[spec-harness] could not clear active turn contract: %s", _exc)
|
||||||
if turn_contract is not None:
|
if turn_contract is not None:
|
||||||
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
|
||||||
if _blocks_before_inference(turn_contract):
|
if not _spec_turn and _blocks_before_inference(turn_contract):
|
||||||
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
unavailable = ", ".join(sorted(turn_contract.unavailable))
|
||||||
clarification = (
|
clarification = (
|
||||||
"I can’t perform this request with the currently permitted tools "
|
"I can’t perform this request with the currently permitted tools "
|
||||||
@@ -24361,7 +24336,7 @@ async def stream_agent_loop(
|
|||||||
# "spec" = compact tools plus a small-model router; the harness-facing
|
# "spec" = compact tools plus a small-model router; the harness-facing
|
||||||
# surface already reads "compact", so detect spec from the raw mode.
|
# surface already reads "compact", so detect spec from the raw mode.
|
||||||
try:
|
try:
|
||||||
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
|
_spec_surface = _configured_model_tool_surface_raw(
|
||||||
candidate_url, candidate_model, owner, headers=candidate_headers,
|
candidate_url, candidate_model, owner, headers=candidate_headers,
|
||||||
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -24435,7 +24410,7 @@ async def stream_agent_loop(
|
|||||||
if _spec_mode == "strict" and _spec_decided:
|
if _spec_mode == "strict" and _spec_decided:
|
||||||
# The router decides which tools are injected: its pick(s),
|
# The router decides which tools are injected: its pick(s),
|
||||||
# tools already used in this chat, and the always-on basics.
|
# tools already used in this chat, and the always-on basics.
|
||||||
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
|
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) - set(disabled_tools)
|
||||||
if _spec_strict:
|
if _spec_strict:
|
||||||
route_tools = _spec_strict
|
route_tools = _spec_strict
|
||||||
_spec_offered = frozenset(route_tools)
|
_spec_offered = frozenset(route_tools)
|
||||||
@@ -25386,6 +25361,15 @@ async def stream_agent_loop(
|
|||||||
if (schema.get("function", {}).get("name") or schema.get("name"))
|
if (schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
in _spec_names
|
in _spec_names
|
||||||
]
|
]
|
||||||
|
_have = {
|
||||||
|
(schema.get("function", {}).get("name") or schema.get("name"))
|
||||||
|
for schema in contract_schemas
|
||||||
|
}
|
||||||
|
contract_schemas += [
|
||||||
|
schema for schema in list(FUNCTION_TOOL_SCHEMAS) + list(route_mcp_schemas)
|
||||||
|
if schema.get("function", {}).get("name") in (set(_spec_names) - _have)
|
||||||
|
and schema.get("function", {}).get("name") not in disabled_tools
|
||||||
|
]
|
||||||
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
|
||||||
if route_state["is_api_model"]:
|
if route_state["is_api_model"]:
|
||||||
if tool_surface == "full":
|
if tool_surface == "full":
|
||||||
@@ -26337,7 +26321,7 @@ async def stream_agent_loop(
|
|||||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||||
if round_num == 1:
|
if round_num == 1:
|
||||||
try:
|
try:
|
||||||
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
|
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or "")), (m.get("metadata") or {}).get("source"), str(m.get("content") or "")[:40].replace("\n", " ")) for m in messages])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
|
||||||
@@ -33941,6 +33925,7 @@ async def stream_agent_loop(
|
|||||||
"mutation_epoch": _workspace_mutation_epoch,
|
"mutation_epoch": _workspace_mutation_epoch,
|
||||||
"error": _failure_text[:600],
|
"error": _failure_text[:600],
|
||||||
}
|
}
|
||||||
|
logger.info("[spec-debug] tool failure text: %s", _failure_text[:400])
|
||||||
logger.info(
|
logger.info(
|
||||||
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
"[agent] recorded failed call %s in round %s at mutation epoch %s",
|
||||||
_call_signature,
|
_call_signature,
|
||||||
|
|||||||
@@ -585,9 +585,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
return _ALIASES_SET.get(k2, (k or "").strip())
|
||||||
|
|
||||||
def _is_managed_key(key):
|
def _is_managed_key(key):
|
||||||
# Tool policy is not a general setting, even if a future default
|
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
||||||
# is added: resetting it must never re-enable disabled tools.
|
|
||||||
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
|
|
||||||
|
|
||||||
_ENUMS = {
|
_ENUMS = {
|
||||||
"image_quality": ["low", "medium", "high"],
|
"image_quality": ["low", "medium", "high"],
|
||||||
@@ -715,35 +713,13 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
save_settings(s)
|
save_settings(s)
|
||||||
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
|
||||||
|
|
||||||
elif action == "enable_tool":
|
elif action in ("disable_tool", "enable_tool", "list_tools"):
|
||||||
# The global disabled_tools denylist is the one hard permission
|
|
||||||
# boundary the Settings -> Agent Tools panel offers admins. It
|
|
||||||
# must only ever be *narrowed* from inside an agent turn: a
|
|
||||||
# model that can call enable_tool can undo the boundary itself,
|
|
||||||
# and any web page, email, or document the agent reads can ask
|
|
||||||
# it to (prompt injection re-enabling bash/email/etc.). Widening
|
|
||||||
# the tool set stays a human action in the Settings panel.
|
|
||||||
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
|
|
||||||
return {
|
|
||||||
"error": (
|
|
||||||
f"Re-enabling tools is not available from chat"
|
|
||||||
f"{f' (requested: {tool_name})' if tool_name else ''}. "
|
|
||||||
"Disabled tools are an admin policy: ask the user to turn the tool "
|
|
||||||
"back on in Settings > Agent Tools. You can still disable tools "
|
|
||||||
"(disable_tool) or list the current state (list_tools)."
|
|
||||||
),
|
|
||||||
"exit_code": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
elif action in ("disable_tool", "list_tools"):
|
|
||||||
# Tool-toggle actions. These edit settings.json:disabled_tools
|
# Tool-toggle actions. These edit settings.json:disabled_tools
|
||||||
# (the global list read on every chat request) rather than
|
# (the global list read on every chat request) rather than
|
||||||
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
|
||||||
# "search" -> "web_search", "browser" -> browser tools,
|
# "search" -> "web_search", "browser" -> browser tools,
|
||||||
# "documents" -> the document tool set, "memory" ->
|
# "documents" -> the document tool set, "memory" ->
|
||||||
# manage_memory, etc. Only disable_tool mutates: see the
|
# manage_memory, etc.
|
||||||
# enable_tool branch above for why the agent can never widen
|
|
||||||
# the denylist.
|
|
||||||
from src.settings import get_setting, save_settings, load_settings
|
from src.settings import get_setting, save_settings, load_settings
|
||||||
_ALIASES = {
|
_ALIASES = {
|
||||||
"shell": ["bash"],
|
"shell": ["bash"],
|
||||||
@@ -777,8 +753,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"response": (
|
"response": (
|
||||||
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
|
||||||
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
"Common toggles: shell (bash), search (web_search), browser, documents, "
|
||||||
"memory, skills, images, tasks, notes, calendar, email. "
|
"memory, skills, images, tasks, notes, calendar, email."
|
||||||
"To re-enable a tool, open Settings > Agent Tools."
|
|
||||||
),
|
),
|
||||||
"disabled": list(current),
|
"disabled": list(current),
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
@@ -792,19 +767,22 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
current = list(settings.get("disabled_tools") or [])
|
current = list(settings.get("disabled_tools") or [])
|
||||||
before = set(current)
|
before = set(current)
|
||||||
for t in targets:
|
if action == "disable_tool":
|
||||||
if t not in current:
|
for t in targets:
|
||||||
current.append(t)
|
if t not in current:
|
||||||
|
current.append(t)
|
||||||
|
else: # enable_tool
|
||||||
|
current = [t for t in current if t not in targets]
|
||||||
after = set(current)
|
after = set(current)
|
||||||
settings["disabled_tools"] = current
|
settings["disabled_tools"] = current
|
||||||
save_settings(settings)
|
save_settings(settings)
|
||||||
|
|
||||||
|
verb = "Disabled" if action == "disable_tool" else "Enabled"
|
||||||
changed = sorted(after.symmetric_difference(before))
|
changed = sorted(after.symmetric_difference(before))
|
||||||
return {
|
return {
|
||||||
"response": (
|
"response": (
|
||||||
f"Disabled {tool_name} ({', '.join(targets)}). "
|
f"{verb} {tool_name} ({', '.join(targets)}). "
|
||||||
f"Now disabled: {', '.join(current) if current else '(none)'}. "
|
f"Now disabled: {', '.join(current) if current else '(none)'}."
|
||||||
"Re-enabling is done by the user in Settings > Agent Tools."
|
|
||||||
),
|
),
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"disabled": list(current),
|
"disabled": list(current),
|
||||||
|
|||||||
@@ -78,16 +78,7 @@ def _parse(content: Any) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
|
||||||
# Explicit editor session bound in context is authoritative;
|
return str(args.get("session") or args.get("editor_session") or ctx.get("session_id") or "default")
|
||||||
# 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]:
|
||||||
@@ -116,26 +107,17 @@ 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"
|
||||||
image_path = root / filename
|
(root / filename).write_bytes(image_bytes)
|
||||||
image_path.write_bytes(image_bytes)
|
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
width = height = 0
|
width = height = 0
|
||||||
try:
|
try:
|
||||||
@@ -181,11 +163,6 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
|||||||
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
|
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()
|
||||||
@@ -221,7 +198,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 bool(data.get("ok") is True and data.get("completed", True) is not False)
|
return data.get("ok") is not False
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
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
|
||||||
@@ -390,46 +367,28 @@ 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, "completed": False}
|
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1}
|
||||||
params = args.get("params") if isinstance(args.get("params"), dict) else {}
|
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": wait,
|
"wait": bool(args.get("wait", True)),
|
||||||
"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, "completed": False, "status": "failed"}
|
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1}
|
||||||
|
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, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
summary = json.dumps({"ok": ok, "error": data.get("error"), "result": result}, ensure_ascii=False)
|
||||||
return f"run_editor_command: {op}", {
|
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 else 1,
|
"exit_code": 0 if ok is not False else 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -483,14 +442,10 @@ class AiEditImageTool:
|
|||||||
image = _as_data_url(args.get("image"))
|
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
|
||||||
@@ -595,13 +550,10 @@ class AiEditImageTool:
|
|||||||
"model": data.get("model") or model_choice or "",
|
"model": data.get("model") or model_choice or "",
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
}
|
}
|
||||||
if _incognito(ctx):
|
try:
|
||||||
info["image_url"] = data_url
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
|
||||||
else:
|
except Exception as exc:
|
||||||
try:
|
logger.warning("ai_edit_image persist failed: %s", exc)
|
||||||
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(
|
||||||
@@ -674,13 +626,10 @@ 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,
|
||||||
}
|
}
|
||||||
if _incognito(ctx):
|
try:
|
||||||
info["image_url"] = data_url
|
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
|
||||||
else:
|
except Exception as exc:
|
||||||
try:
|
logger.warning("generate_image persist failed: %s", exc)
|
||||||
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,
|
||||||
|
|||||||
@@ -211,15 +211,10 @@ class ChatHandler:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
vision_enabled = get_setting("vision_enabled", True)
|
vision_enabled = get_setting("vision_enabled", True)
|
||||||
if vision_enabled:
|
if vision_enabled:
|
||||||
from src.owner_identity import auth_disabled
|
|
||||||
sess_owner = getattr(sess, "owner", None)
|
|
||||||
if not sess_owner and not auth_disabled():
|
|
||||||
sess_owner = ""
|
|
||||||
main_is_vision = await asyncio.to_thread(
|
main_is_vision = await asyncio.to_thread(
|
||||||
model_supports_vision,
|
model_supports_vision,
|
||||||
sess.model or "",
|
sess.model or "",
|
||||||
getattr(sess, "endpoint_url", "") or "",
|
getattr(sess, "endpoint_url", "") or "",
|
||||||
sess_owner,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if effective_att_ids and vision_enabled:
|
if effective_att_ids and vision_enabled:
|
||||||
|
|||||||
+24
-243
@@ -5,14 +5,9 @@ import re
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import socket
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
import typing
|
|
||||||
import httpcore
|
|
||||||
from httpcore._backends.sync import SyncBackend, SyncStream
|
|
||||||
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
@@ -22,9 +17,6 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ORIGINAL_HTTPX_GET = httpx.get
|
|
||||||
_ORIGINAL_HTTPX_POST = httpx.post
|
|
||||||
|
|
||||||
|
|
||||||
def extract_urls(text: str) -> List[str]:
|
def extract_urls(text: str) -> List[str]:
|
||||||
"""Extract URLs from text using regex pattern."""
|
"""Extract URLs from text using regex pattern."""
|
||||||
@@ -109,167 +101,23 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
|
|||||||
_lmstudio_models_cache: dict = {}
|
_lmstudio_models_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
_BLOCKED_METADATA_IPS = {
|
def _is_local_host(host: Optional[str]) -> bool:
|
||||||
ipaddress.ip_address("169.254.169.254"),
|
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
|
||||||
ipaddress.ip_address("169.254.169.253"),
|
host = (host or "").lower()
|
||||||
ipaddress.ip_address("100.100.100.200"),
|
|
||||||
}
|
|
||||||
|
|
||||||
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
|
||||||
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
||||||
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
|
|
||||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
||||||
ip = ip.ipv4_mapped
|
|
||||||
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
|
|
||||||
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
|
||||||
|
|
||||||
if ip in _BLOCKED_METADATA_IPS:
|
|
||||||
return False
|
|
||||||
if ip.is_link_local:
|
|
||||||
return False
|
|
||||||
# Explicit loopback check before reserved rejection:
|
|
||||||
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
|
|
||||||
if ip.is_loopback:
|
|
||||||
return True
|
|
||||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
|
||||||
return False
|
|
||||||
if ip.is_private:
|
|
||||||
return True
|
|
||||||
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_safe_local_host(host: Optional[str]) -> bool:
|
|
||||||
"""True for loopback/LAN/Tailscale hosts.
|
|
||||||
|
|
||||||
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
|
|
||||||
unresolved single-label names, and public domains.
|
|
||||||
"""
|
|
||||||
host = (host or "").strip().lower()
|
|
||||||
if not host:
|
if not host:
|
||||||
return False
|
return False
|
||||||
# Strip optional IPv6 square brackets: [::1] -> ::1
|
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
|
||||||
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
return True
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(literal)
|
ip = ipaddress.ip_address(host)
|
||||||
return _is_ip_safe_local(ip)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
return "." not in host
|
||||||
|
if ip.is_loopback or ip.is_private or ip.is_link_local:
|
||||||
allowed_names = {"localhost", "host.docker.internal"}
|
return True
|
||||||
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
|
return ip in ipaddress.ip_network("100.64.0.0/10")
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
infos = socket.getaddrinfo(host, None)
|
|
||||||
if not infos:
|
|
||||||
return False
|
|
||||||
resolved_ips = []
|
|
||||||
for info in infos:
|
|
||||||
sockaddr = info[4]
|
|
||||||
ip_str = str(sockaddr[0]).split("%")[0]
|
|
||||||
resolved_ips.append(ipaddress.ip_address(ip_str))
|
|
||||||
if not resolved_ips:
|
|
||||||
return False
|
|
||||||
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
_is_local_host = _is_safe_local_host
|
def _probe_lmstudio_models(url: str) -> Optional[list]:
|
||||||
|
|
||||||
|
|
||||||
class SafeProbeBackend(SyncBackend):
|
|
||||||
"""Enforces SSRF address policies at TCP connection time.
|
|
||||||
|
|
||||||
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
|
|
||||||
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
|
|
||||||
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def connect_tcp(
|
|
||||||
self,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
timeout: float | None = None,
|
|
||||||
local_address: str | None = None,
|
|
||||||
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
|
|
||||||
) -> httpcore.NetworkStream:
|
|
||||||
source_address = None if local_address is None else (local_address, 0)
|
|
||||||
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
|
|
||||||
with map_exceptions(exc_map):
|
|
||||||
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
|
|
||||||
if not infos:
|
|
||||||
raise ConnectError(f"Host {host} did not resolve")
|
|
||||||
for family, socktype, proto, _canon, sockaddr in infos:
|
|
||||||
ip_str = str(sockaddr[0]).split("%")[0]
|
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(ip_str)
|
|
||||||
except ValueError:
|
|
||||||
raise ConnectError(f"Invalid resolved IP: {ip_str}")
|
|
||||||
if not _is_ip_safe_local(ip):
|
|
||||||
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
|
|
||||||
last_err: Optional[OSError] = None
|
|
||||||
for family, socktype, proto, _canon, sockaddr in infos:
|
|
||||||
sock = socket.socket(family, socktype, proto)
|
|
||||||
try:
|
|
||||||
if timeout is not None:
|
|
||||||
sock.settimeout(timeout)
|
|
||||||
if source_address:
|
|
||||||
sock.bind(source_address)
|
|
||||||
sock.connect(sockaddr)
|
|
||||||
if socket_options:
|
|
||||||
for opt in socket_options:
|
|
||||||
sock.setsockopt(*opt)
|
|
||||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
||||||
return SyncStream(sock)
|
|
||||||
except OSError as e:
|
|
||||||
last_err = e
|
|
||||||
sock.close()
|
|
||||||
if last_err is not None:
|
|
||||||
raise last_err
|
|
||||||
raise ConnectError(f"Could not connect to {host}:{port}")
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
|
|
||||||
if httpx.get is not _ORIGINAL_HTTPX_GET:
|
|
||||||
try:
|
|
||||||
if headers:
|
|
||||||
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
|
|
||||||
return httpx.get(url, timeout=timeout, follow_redirects=False)
|
|
||||||
except TypeError:
|
|
||||||
if headers:
|
|
||||||
return httpx.get(url, headers=headers, timeout=timeout)
|
|
||||||
return httpx.get(url, timeout=timeout)
|
|
||||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
|
||||||
transport = httpx.HTTPTransport(trust_env=False)
|
|
||||||
transport._pool = pool
|
|
||||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
|
||||||
return client.get(url, headers=headers, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
|
|
||||||
if httpx.post is not _ORIGINAL_HTTPX_POST:
|
|
||||||
try:
|
|
||||||
if headers:
|
|
||||||
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
|
|
||||||
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
|
|
||||||
except TypeError:
|
|
||||||
if headers:
|
|
||||||
return httpx.post(url, headers=headers, json=json, timeout=timeout)
|
|
||||||
return httpx.post(url, json=json, timeout=timeout)
|
|
||||||
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
|
|
||||||
transport = httpx.HTTPTransport(trust_env=False)
|
|
||||||
transport._pool = pool
|
|
||||||
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
|
|
||||||
return client.post(url, headers=headers, json=json, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
|
|
||||||
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
|
||||||
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -279,10 +127,10 @@ def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional
|
|||||||
cached = _lmstudio_models_cache.get(key)
|
cached = _lmstudio_models_cache.get(key)
|
||||||
if cached is not None and cached[1] > now:
|
if cached is not None and cached[1] > now:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
|
authority = host if parsed.port is None else f"{host}:{parsed.port}"
|
||||||
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
|
||||||
try:
|
try:
|
||||||
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
|
r = httpx.get(probe_url, timeout=1.0)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -300,15 +148,15 @@ def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional
|
|||||||
return models
|
return models
|
||||||
|
|
||||||
|
|
||||||
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
|
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
|
||||||
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
|
||||||
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
|
||||||
if not model:
|
if not model:
|
||||||
return None
|
return None
|
||||||
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
|
# Never probe a remote provider; LM Studio is always a local/LAN host.
|
||||||
if not _is_safe_local_host(urlparse(url).hostname):
|
if not _is_local_host(urlparse(url).hostname):
|
||||||
return None
|
return None
|
||||||
models = _probe_lmstudio_models(url, headers=headers)
|
models = _probe_lmstudio_models(url)
|
||||||
if not models:
|
if not models:
|
||||||
return None
|
return None
|
||||||
want = model.strip().lower()
|
want = model.strip().lower()
|
||||||
@@ -324,79 +172,21 @@ def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = Non
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def model_supports_vision(
|
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
|
||||||
model_name: str,
|
|
||||||
endpoint_url: str = "",
|
|
||||||
owner: Optional[str] = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Whether a model accepts images, using the endpoint's reported
|
"""Whether a model accepts images, using the endpoint's reported
|
||||||
capability when available (LM Studio), a cached one-time probe for local
|
capability when available (LM Studio), a cached one-time probe for local
|
||||||
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
OpenAI-compatible endpoints, and name-based detection otherwise."""
|
||||||
if is_vision_model(model_name):
|
if is_vision_model(model_name):
|
||||||
return True
|
return True
|
||||||
if endpoint_url:
|
if endpoint_url:
|
||||||
target_url = endpoint_url
|
|
||||||
headers: Optional[dict] = None
|
|
||||||
if owner is not None:
|
|
||||||
if not owner:
|
|
||||||
# Authentication enabled, but caller has no owner: refuse probing immediately
|
|
||||||
return is_vision_model(model_name)
|
|
||||||
try:
|
|
||||||
from src.database import SessionLocal
|
|
||||||
from src.endpoint_resolver import (
|
|
||||||
normalize_base,
|
|
||||||
resolve_endpoint_runtime,
|
|
||||||
resolve_owner_registered_endpoint,
|
|
||||||
)
|
|
||||||
with SessionLocal() as db:
|
|
||||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
|
|
||||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
|
||||||
target_url = normalize_base(resolved_base or ep.base_url)
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
|
||||||
except ValueError:
|
|
||||||
# Foreign-owned, disabled, or unregistered endpoint
|
|
||||||
return is_vision_model(model_name)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Endpoint authorization check failed: %s", e)
|
|
||||||
return is_vision_model(model_name)
|
|
||||||
else:
|
|
||||||
# Single-user / auth-disabled mode (owner is None):
|
|
||||||
try:
|
|
||||||
from src.database import SessionLocal
|
|
||||||
from src.endpoint_resolver import (
|
|
||||||
normalize_base,
|
|
||||||
resolve_endpoint_runtime,
|
|
||||||
resolve_owner_registered_endpoint,
|
|
||||||
)
|
|
||||||
with SessionLocal() as db:
|
|
||||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
|
|
||||||
resolved_base, api_key = resolve_endpoint_runtime(ep)
|
|
||||||
target_url = normalize_base(resolved_base or ep.base_url)
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
|
||||||
except Exception:
|
|
||||||
# In single-user mode, an unregistered endpoint is allowed
|
|
||||||
# only if target URL passes safe local host checks (probed with headers=None).
|
|
||||||
target_url = endpoint_url
|
|
||||||
headers = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if headers:
|
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
|
||||||
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
|
|
||||||
else:
|
|
||||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
|
||||||
except TypeError:
|
|
||||||
advertised = lmstudio_supports_vision(target_url, model_name or "")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
advertised = None
|
advertised = None
|
||||||
if advertised is not None:
|
if advertised is not None:
|
||||||
return advertised
|
return advertised
|
||||||
try:
|
try:
|
||||||
if headers:
|
probed = probe_endpoint_supports_vision(endpoint_url, model_name or "")
|
||||||
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
|
|
||||||
else:
|
|
||||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
|
||||||
except TypeError:
|
|
||||||
probed = probe_endpoint_supports_vision(target_url, model_name or "")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
probed = None
|
probed = None
|
||||||
if probed is not None:
|
if probed is not None:
|
||||||
@@ -425,11 +215,7 @@ def _chat_completions_url(endpoint_url: str) -> str:
|
|||||||
return base + "/chat/completions"
|
return base + "/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
def probe_endpoint_supports_vision(
|
def probe_endpoint_supports_vision(endpoint_url: str, model_name: str) -> Optional[bool]:
|
||||||
endpoint_url: str,
|
|
||||||
model_name: str,
|
|
||||||
headers: Optional[dict] = None,
|
|
||||||
) -> Optional[bool]:
|
|
||||||
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
|
||||||
an image_url content part for `model_name`?
|
an image_url content part for `model_name`?
|
||||||
|
|
||||||
@@ -440,8 +226,8 @@ def probe_endpoint_supports_vision(
|
|||||||
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
if not endpoint_url or not model_name or not _vision_probe_enabled():
|
||||||
return None
|
return None
|
||||||
parsed = urlparse(endpoint_url)
|
parsed = urlparse(endpoint_url)
|
||||||
if not _is_safe_local_host(parsed.hostname):
|
if not _is_local_host(parsed.hostname):
|
||||||
return None # never probe a public provider or disallowed host
|
return None # never probe a public provider
|
||||||
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = _vision_probe_cache.get(key)
|
cached = _vision_probe_cache.get(key)
|
||||||
@@ -461,12 +247,7 @@ def probe_endpoint_supports_vision(
|
|||||||
}
|
}
|
||||||
verdict: Optional[bool] = None
|
verdict: Optional[bool] = None
|
||||||
try:
|
try:
|
||||||
r = _safe_probe_post(
|
r = httpx.post(_chat_completions_url(endpoint_url), json=payload, timeout=8.0)
|
||||||
_chat_completions_url(endpoint_url),
|
|
||||||
headers=headers,
|
|
||||||
json=payload,
|
|
||||||
timeout=8.0,
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
verdict = True
|
verdict = True
|
||||||
elif r.status_code in (400, 413, 415, 422):
|
elif r.status_code in (400, 413, 415, 422):
|
||||||
|
|||||||
@@ -1466,29 +1466,6 @@ def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_us
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
|
|
||||||
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
|
|
||||||
try:
|
|
||||||
import sys as _sys
|
|
||||||
spec_router = _sys.modules.get('src.spec_router')
|
|
||||||
if spec_router is None:
|
|
||||||
from src import spec_router # noqa: F401
|
|
||||||
if not spec_router.think_router_enabled():
|
|
||||||
return None
|
|
||||||
from src.agent_loop import _configured_model_tool_surface_raw
|
|
||||||
if _configured_model_tool_surface_raw(
|
|
||||||
endpoint_url, model, owner, headers=headers
|
|
||||||
) != 'spec':
|
|
||||||
return None
|
|
||||||
if hasattr(spec_router, 'route_thinking_async'):
|
|
||||||
return await spec_router.route_thinking_async(direct_user_text)
|
|
||||||
return spec_router.route_thinking(direct_user_text)
|
|
||||||
except Exception:
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
'spec think router decision failed; using rules only', exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
def spec_router_think_reason(round_number, decision, existing_deliberate):
|
||||||
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
"""Add 'router_think' on round 1 when the router asked for thinking and no
|
||||||
other rule already selected a deliberate reason."""
|
other rule already selected a deliberate reason."""
|
||||||
@@ -1531,7 +1508,10 @@ def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
|
|||||||
spec_router = _sys.modules.get('src.spec_router')
|
spec_router = _sys.modules.get('src.spec_router')
|
||||||
if spec_router is None:
|
if spec_router is None:
|
||||||
from src import spec_router # noqa: F401
|
from src import spec_router # noqa: F401
|
||||||
decision = spec_router.latest_decision()
|
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||||
|
if not decisions:
|
||||||
|
return None
|
||||||
|
decision = decisions[-1]
|
||||||
if not isinstance(decision, dict):
|
if not isinstance(decision, dict):
|
||||||
return None
|
return None
|
||||||
# Guard against a stale decision from a previous turn.
|
# Guard against a stale decision from a previous turn.
|
||||||
@@ -1593,7 +1573,8 @@ def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
|
|||||||
set(disabled_tools or ()), list(conversation or []),
|
set(disabled_tools or ()), list(conversation or []),
|
||||||
return_decision=True,
|
return_decision=True,
|
||||||
)
|
)
|
||||||
decision = spec_router.latest_decision() or {}
|
decisions = getattr(spec_router, 'LAST_DECISIONS', None) or []
|
||||||
|
decision = decisions[-1] if decisions else {}
|
||||||
if not isinstance(decision, dict) or decision.get('error'):
|
if not isinstance(decision, dict) or decision.get('error'):
|
||||||
return None
|
return None
|
||||||
choice = decision.get('choice')
|
choice = decision.get('choice')
|
||||||
@@ -3881,9 +3862,6 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
|
|||||||
or bare in BROKERED_JOB_TOOLS
|
or bare in BROKERED_JOB_TOOLS
|
||||||
or (contract_required and bare in {'edit_image', 'generate_image'})
|
or (contract_required and bare in {'edit_image', 'generate_image'})
|
||||||
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
|
||||||
# The editor command is conservatively classified as a write;
|
|
||||||
# document.info is a read even when the user requested no edit.
|
|
||||||
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
|
||||||
@@ -5622,10 +5600,6 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
external_tool_schemas=None, temperature=0.0,
|
external_tool_schemas=None, temperature=0.0,
|
||||||
context_resolution=None,
|
context_resolution=None,
|
||||||
request_authority=MISSING_AUTHORITY, **ignored):
|
request_authority=MISSING_AUTHORITY, **ignored):
|
||||||
if spec_driver_active(endpoint_url, model, owner, headers):
|
|
||||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
|
||||||
yield 'data: [DONE]\n\n'
|
|
||||||
return
|
|
||||||
from src.generation_sampling import validate_temperature
|
from src.generation_sampling import validate_temperature
|
||||||
temperature = validate_temperature(temperature)
|
temperature = validate_temperature(temperature)
|
||||||
# This path sends requests directly with httpx and therefore bypasses
|
# This path sends requests directly with httpx and therefore bypasses
|
||||||
@@ -6121,7 +6095,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
has_real_usage = intent_accounting.get('usage_source') == 'real'
|
||||||
# Optional env-gated router decision (spec surface only). Computed
|
# Optional env-gated router decision (spec surface only). Computed
|
||||||
# once per user turn; disabled/None leaves behaviour unchanged.
|
# once per user turn; disabled/None leaves behaviour unchanged.
|
||||||
_spec_route_think = await spec_router_thinking_decision_async(
|
_spec_route_think = spec_router_thinking_decision(
|
||||||
endpoint_url, model, owner, headers, direct_user_text)
|
endpoint_url, model, owner, headers, direct_user_text)
|
||||||
if _spec_route_think is not None:
|
if _spec_route_think is not None:
|
||||||
logging.getLogger(__name__).info(
|
logging.getLogger(__name__).info(
|
||||||
@@ -6134,17 +6108,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
# round 1 is sent with tool_choice 'none' (same tools list).
|
# round 1 is sent with tool_choice 'none' (same tools list).
|
||||||
_spec_direct_p = spec_direct_no_tools_p(
|
_spec_direct_p = spec_direct_no_tools_p(
|
||||||
direct_user_text, endpoint_url, model, owner, headers)
|
direct_user_text, endpoint_url, model, owner, headers)
|
||||||
if (turn_contract.required or turn_contract.unavailable
|
|
||||||
or turn_contract.required_read_operation is not None):
|
|
||||||
_spec_direct_p = None
|
|
||||||
# Option B: send round 1 with no tools at all for a direct answer.
|
# Option B: send round 1 with no tools at all for a direct answer.
|
||||||
_spec_direct_empty_menu = (
|
_spec_direct_empty_menu = (
|
||||||
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
|
||||||
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
|
||||||
# every round; the 9B only writes the arguments or the answer.
|
# every round; the 9B only writes the arguments or the answer.
|
||||||
# Driver execution is refused at entry. Keep this false even if
|
_spec_driver_on = spec_driver_active(endpoint_url, model, owner, headers)
|
||||||
# configuration changes while the stream is already in progress.
|
|
||||||
_spec_driver_on = False
|
|
||||||
# Driver mode narrows the contract per round to the Spec pick.
|
# Driver mode narrows the contract per round to the Spec pick.
|
||||||
# Rebuild each round from this frozen base so the narrowing does
|
# Rebuild each round from this frozen base so the narrowing does
|
||||||
# not accumulate/mutate across rounds.
|
# not accumulate/mutate across rounds.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ def prune_multimodal_images(
|
|||||||
return pruned
|
return pruned
|
||||||
|
|
||||||
|
|
||||||
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
|
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:\s+attached)?:\s*([^\]]+)\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _image_turn_label(message: Dict) -> str:
|
def _image_turn_label(message: Dict) -> str:
|
||||||
@@ -102,8 +102,7 @@ def _image_turn_label(message: Dict) -> str:
|
|||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
for item in content:
|
for item in content:
|
||||||
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
|
||||||
image_url = item.get("image_url")
|
candidate = item.get("name") or (item.get("image_url") or {}).get("name")
|
||||||
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
|
|
||||||
if candidate:
|
if candidate:
|
||||||
name = str(candidate)
|
name = str(candidate)
|
||||||
break
|
break
|
||||||
@@ -147,7 +146,7 @@ def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict
|
|||||||
trimmed.append(message)
|
trimmed.append(message)
|
||||||
continue
|
continue
|
||||||
kept_blocks = [
|
kept_blocks = [
|
||||||
dict(item) if isinstance(item, dict) else item for item in content
|
item for item in content
|
||||||
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
|
||||||
]
|
]
|
||||||
if len(kept_blocks) == len(content):
|
if len(kept_blocks) == len(content):
|
||||||
|
|||||||
+6
-240
@@ -339,231 +339,14 @@ async def self_check(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _resolve_override_candidate(override: str, owner: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Resolve an explicit agent_model override under server authority.
|
|
||||||
|
|
||||||
Guarantees:
|
|
||||||
- Client-provided raw URLs never determine arbitrary outbound network destinations.
|
|
||||||
- Registered endpoint IDs resolve only to enabled endpoints visible to the owner.
|
|
||||||
- Shared endpoints (owner IS NULL) remain visible to authorized users.
|
|
||||||
- Disabled, foreign-owned, forged, or unconfigured destinations fail closed (return None).
|
|
||||||
- Admin privileges do not allow arbitrary unconfigured destinations.
|
|
||||||
"""
|
|
||||||
raw = str(override or "").strip()
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "::" in raw:
|
|
||||||
target, requested_model = raw.split("::", 1)
|
|
||||||
target = target.strip()
|
|
||||||
requested_model = requested_model.strip()
|
|
||||||
else:
|
|
||||||
target = ""
|
|
||||||
requested_model = raw
|
|
||||||
|
|
||||||
# 1. Explicit local selection or matching a configured local base
|
|
||||||
if target == "local":
|
|
||||||
bases = local_bases()
|
|
||||||
if not bases:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"base": bases[0],
|
|
||||||
"model": requested_model or local_model(),
|
|
||||||
"headers": None,
|
|
||||||
"source": "override",
|
|
||||||
}
|
|
||||||
for lb in local_bases():
|
|
||||||
if target and target.rstrip("/") == lb.rstrip("/"):
|
|
||||||
return {
|
|
||||||
"base": lb,
|
|
||||||
"model": requested_model or local_model(),
|
|
||||||
"headers": None,
|
|
||||||
"source": "override",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2. Explicit hosted fallback selection
|
|
||||||
try:
|
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
|
||||||
|
|
||||||
for prefix in ("utility", "default"):
|
|
||||||
try:
|
|
||||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if url and model:
|
|
||||||
if target in ("default", "hosted"):
|
|
||||||
return {
|
|
||||||
"base": url,
|
|
||||||
"model": requested_model or model,
|
|
||||||
"headers": headers,
|
|
||||||
"source": "hosted",
|
|
||||||
}
|
|
||||||
if target and target.rstrip("/") == url.rstrip("/"):
|
|
||||||
if not requested_model or requested_model == model:
|
|
||||||
return {
|
|
||||||
"base": url,
|
|
||||||
"model": requested_model or model,
|
|
||||||
"headers": headers,
|
|
||||||
"source": "hosted",
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if target in ("default", "hosted"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 3. Bare model id without endpoint prefix
|
|
||||||
if not target:
|
|
||||||
if requested_model == "local":
|
|
||||||
bases = local_bases()
|
|
||||||
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"} if bases else None
|
|
||||||
if requested_model in ("default", "hosted"):
|
|
||||||
return _resolve_override_candidate("default::", owner)
|
|
||||||
|
|
||||||
# Exact match with local model
|
|
||||||
if requested_model == local_model():
|
|
||||||
bases = local_bases()
|
|
||||||
if bases:
|
|
||||||
return {"base": bases[0], "model": local_model(), "headers": None, "source": "local"}
|
|
||||||
|
|
||||||
# Exact match with hosted default/utility
|
|
||||||
try:
|
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
|
||||||
|
|
||||||
for prefix in ("utility", "default"):
|
|
||||||
try:
|
|
||||||
url, model, headers = resolve_endpoint(prefix, owner=owner)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if url and model and model == requested_model:
|
|
||||||
return {"base": url, "model": model, "headers": headers, "source": "hosted"}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check registered database endpoints for this bare model
|
|
||||||
try:
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
|
||||||
from src.auth_helpers import owner_filter, _auth_disabled
|
|
||||||
from src.endpoint_resolver import (
|
|
||||||
resolve_endpoint_runtime,
|
|
||||||
build_chat_url,
|
|
||||||
build_headers,
|
|
||||||
_endpoint_enabled_models,
|
|
||||||
_endpoint_hidden_models,
|
|
||||||
)
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if not owner and not _auth_disabled():
|
|
||||||
q = db.query(ModelEndpoint).filter(
|
|
||||||
ModelEndpoint.is_enabled == True,
|
|
||||||
ModelEndpoint.owner == None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
q = owner_filter(
|
|
||||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
|
||||||
ModelEndpoint,
|
|
||||||
owner,
|
|
||||||
)
|
|
||||||
matching_eps = []
|
|
||||||
for ep in q.all():
|
|
||||||
enabled_models = _endpoint_enabled_models(ep)
|
|
||||||
hidden_models = _endpoint_hidden_models(ep)
|
|
||||||
if requested_model in enabled_models and requested_model not in hidden_models:
|
|
||||||
matching_eps.append(ep)
|
|
||||||
|
|
||||||
# Avoid model ID collision across providers: only accept if unique
|
|
||||||
if len(matching_eps) == 1:
|
|
||||||
ep = matching_eps[0]
|
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
return {
|
|
||||||
"base": build_chat_url(base),
|
|
||||||
"model": requested_model,
|
|
||||||
"headers": build_headers(api_key, base),
|
|
||||||
"source": "registered",
|
|
||||||
"endpoint_id": ep.id,
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 4. Target is an endpoint_id or a registered legacy endpoint URL
|
|
||||||
try:
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
|
||||||
from src.auth_helpers import owner_filter, _auth_disabled
|
|
||||||
from src.endpoint_resolver import (
|
|
||||||
resolve_endpoint_runtime,
|
|
||||||
build_chat_url,
|
|
||||||
build_headers,
|
|
||||||
_endpoint_enabled_models,
|
|
||||||
_endpoint_hidden_models,
|
|
||||||
_first_chat_model,
|
|
||||||
)
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if not owner and not _auth_disabled():
|
|
||||||
q = db.query(ModelEndpoint).filter(
|
|
||||||
ModelEndpoint.is_enabled == True,
|
|
||||||
ModelEndpoint.owner == None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
q = owner_filter(
|
|
||||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
|
||||||
ModelEndpoint,
|
|
||||||
owner,
|
|
||||||
)
|
|
||||||
# Lookup by ID
|
|
||||||
ep = q.filter(ModelEndpoint.id == target).first()
|
|
||||||
|
|
||||||
# If not found by ID, match legacy registered base URL (strictly registered rows only)
|
|
||||||
if ep is None and ("://" in target or target.startswith("http")):
|
|
||||||
target_norm = target.rstrip("/")
|
|
||||||
for cand_ep in q.all():
|
|
||||||
cand_base = (cand_ep.base_url or "").rstrip("/")
|
|
||||||
if (
|
|
||||||
cand_base == target_norm
|
|
||||||
or cand_base + "/v1" == target_norm
|
|
||||||
or cand_base == target_norm + "/v1"
|
|
||||||
):
|
|
||||||
ep = cand_ep
|
|
||||||
break
|
|
||||||
|
|
||||||
if ep is None:
|
|
||||||
# Target is disabled, foreign-owned, nonexistent, or arbitrary unconfigured URL -> fail closed!
|
|
||||||
return None
|
|
||||||
|
|
||||||
enabled_models = _endpoint_enabled_models(ep)
|
|
||||||
hidden_models = _endpoint_hidden_models(ep)
|
|
||||||
if requested_model:
|
|
||||||
if requested_model in hidden_models:
|
|
||||||
return None
|
|
||||||
if enabled_models and requested_model not in enabled_models:
|
|
||||||
# Model not served by this endpoint -> fail closed against forged model IDs
|
|
||||||
return None
|
|
||||||
model = requested_model
|
|
||||||
else:
|
|
||||||
model = _first_chat_model(enabled_models)
|
|
||||||
if not model:
|
|
||||||
return None
|
|
||||||
|
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
return {
|
|
||||||
"base": build_chat_url(base),
|
|
||||||
"model": model,
|
|
||||||
"headers": build_headers(api_key, base),
|
|
||||||
"source": "registered",
|
|
||||||
"endpoint_id": ep.id,
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str, Any]]:
|
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:
|
||||||
@@ -579,14 +362,6 @@ def _candidates(owner: Optional[str], override: Optional[str]) -> List[Dict[str,
|
|||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if override:
|
|
||||||
cand = _resolve_override_candidate(override, owner)
|
|
||||||
if cand is not None:
|
|
||||||
cands.insert(0, cand)
|
|
||||||
return cands
|
|
||||||
# Unauthorized, invalid, foreign, disabled, or forged override:
|
|
||||||
# Fail closed! Never silently dispatch sensitive user images/state to an unexpected fallback.
|
|
||||||
return []
|
|
||||||
return cands
|
return cands
|
||||||
|
|
||||||
|
|
||||||
@@ -658,11 +433,9 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
|||||||
"source": "local",
|
"source": "local",
|
||||||
"vision": True,
|
"vision": True,
|
||||||
"base_url": base,
|
"base_url": base,
|
||||||
"endpoint_id": "local",
|
|
||||||
})
|
})
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.settings import get_user_setting, load_settings
|
|
||||||
|
|
||||||
for prefix in ("utility", "default"):
|
for prefix in ("utility", "default"):
|
||||||
try:
|
try:
|
||||||
@@ -673,19 +446,12 @@ async def list_editor_agent_models(owner: Optional[str] = None) -> List[Dict[str
|
|||||||
key = ("hosted", model)
|
key = ("hosted", model)
|
||||||
if key not in seen:
|
if key not in seen:
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
ep_id = None
|
|
||||||
try:
|
|
||||||
settings = load_settings()
|
|
||||||
ep_id = (get_user_setting(f"{prefix}_endpoint_id", owner or "", settings.get(f"{prefix}_endpoint_id", "")) or "").strip() or None
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
models.append({
|
models.append({
|
||||||
"id": model,
|
"id": model,
|
||||||
"label": model,
|
"label": model,
|
||||||
"source": "hosted",
|
"source": "hosted",
|
||||||
"vision": None,
|
"vision": None,
|
||||||
"base_url": url,
|
"base_url": url,
|
||||||
"endpoint_id": ep_id or prefix,
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -486,11 +486,11 @@ _RECOLOR_EXCLUDE_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_RECOLOR_TRAILING_ADJECTIVES = frozenset({
|
_RECOLOR_TRAILING_ADJ = re.compile(
|
||||||
"bright", "brighter", "vivid", "vibrant", "dark", "darker", "light",
|
r"\s+\b(?:bright|brighter|vivid|vibrant|dark|darker|light|lighter|deep|"
|
||||||
"lighter", "deep", "pale", "more", "less", "very", "really",
|
r"pale|more|less|very|really|slightly|totally|completely)\b\s*$",
|
||||||
"slightly", "totally", "completely",
|
re.I,
|
||||||
})
|
)
|
||||||
|
|
||||||
|
|
||||||
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,6 +119,12 @@ 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)",
|
||||||
|
|||||||
+11
-12
@@ -17,7 +17,6 @@ Env:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -99,18 +98,18 @@ def score_value(answer: Optional[Dict[str, Any]]) -> Optional[float]:
|
|||||||
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
"""Expected value (0..n-1) of a jeff ``score`` answer, or None."""
|
||||||
if not isinstance(answer, dict):
|
if not isinstance(answer, dict):
|
||||||
return None
|
return None
|
||||||
try:
|
if answer.get("type") == "score":
|
||||||
if answer.get("type") == "score":
|
try:
|
||||||
value = float(answer.get("score"))
|
return float(answer.get("score"))
|
||||||
elif answer.get("type") == "choice":
|
except Exception:
|
||||||
probs = answer.get("probabilities") or {}
|
|
||||||
value = float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
|
||||||
else:
|
|
||||||
return None
|
return None
|
||||||
except Exception:
|
if answer.get("type") == "choice":
|
||||||
return None
|
probs = answer.get("probabilities") or {}
|
||||||
# A malformed adapter reply must fall back like any other failure.
|
try:
|
||||||
return value if math.isfinite(value) else None
|
return float(sum(i * float(p) for i, p in enumerate(probs.values())))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
def choice_of(answer: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
|||||||
+2
-3
@@ -1556,9 +1556,8 @@ def _build_chatgpt_responses_payload(
|
|||||||
"stream": stream,
|
"stream": stream,
|
||||||
"store": False,
|
"store": False,
|
||||||
}
|
}
|
||||||
# ChatGPT plan Responses requests reject temperature for every model.
|
if not _restricts_temperature(model):
|
||||||
# The common caller still passes its preset value, but this provider must
|
payload["temperature"] = temperature
|
||||||
# omit the field even for model names outside the known reasoning families.
|
|
||||||
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
# ChatGPT Subscription Codex API does not support max_output_tokens —
|
||||||
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
# passing it returns HTTP 400 "Unsupported parameter: max_output_tokens".
|
||||||
# Do not include it in the payload.
|
# Do not include it in the payload.
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ def untrusted_context_message(
|
|||||||
"""
|
"""
|
||||||
safe_label = _sanitize_label(label)
|
safe_label = _sanitize_label(label)
|
||||||
text = "" if content is None else str(content)
|
text = "" if content is None else str(content)
|
||||||
|
import logging as _lg
|
||||||
|
_lg.getLogger("src.prompt_security").info("[spec-debug] untrusted block label=%r content_chars=%d", safe_label[:80], len(text))
|
||||||
text = _escape_guard_markers(text)
|
text = _escape_guard_markers(text)
|
||||||
metadata: Dict[str, Any] = {
|
metadata: Dict[str, Any] = {
|
||||||
"trusted": False,
|
"trusted": False,
|
||||||
|
|||||||
+3
-2
@@ -47,8 +47,9 @@ DEFAULT_SETTINGS = {
|
|||||||
"image_gen_enabled": False,
|
"image_gen_enabled": False,
|
||||||
"image_model": "",
|
"image_model": "",
|
||||||
"image_quality": "medium",
|
"image_quality": "medium",
|
||||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
|
||||||
# fallback without probing a developer-specific host.
|
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
|
||||||
|
# back to the hosted chat model. See src/editor_agent_model.py.
|
||||||
"editor_agent_base_url": "",
|
"editor_agent_base_url": "",
|
||||||
"editor_agent_model": "",
|
"editor_agent_model": "",
|
||||||
"editor_caption_base_url": "",
|
"editor_caption_base_url": "",
|
||||||
|
|||||||
+167
-5
@@ -1,4 +1,13 @@
|
|||||||
"""Helpers for the experimental Spec driver, whose execution is disabled."""
|
"""Ajax Spec turn: the Spec router drives every step; the chat model never chooses a tool.
|
||||||
|
|
||||||
|
Each round the router (spec_router.next_action) picks one action from the full tool surface:
|
||||||
|
tool -> the chat model gets only that tool (named tool_choice) and writes its arguments;
|
||||||
|
the executor runs it; the result goes back to the router for the next pick.
|
||||||
|
answer_directly -> the chat model writes the answer with no tools (tools key omitted).
|
||||||
|
ask_user -> the chat model writes the question through the ask_user tool.
|
||||||
|
Router unavailable -> the chat model answers without tools. Execution keeps the normal request
|
||||||
|
authority and tool policy (execute_tool_block); nothing else is layered on top.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -101,8 +110,161 @@ async def stream_spec_turn(*, endpoint_url, model, messages, headers, session_id
|
|||||||
client_runtime_context=None, external_untrusted_context_seen=False,
|
client_runtime_context=None, external_untrusted_context_seen=False,
|
||||||
max_tokens=768, max_rounds=8, temperature=0.0,
|
max_tokens=768, max_rounds=8, temperature=0.0,
|
||||||
request_authority=MISSING_AUTHORITY, **ignored):
|
request_authority=MISSING_AUTHORITY, **ignored):
|
||||||
# This experimental runtime lacks the normal turn and completion gates.
|
from src.turn_contract import _ACTIVE_CONTRACT
|
||||||
# Refuse direct callers too, before contacting a router or executor.
|
_ACTIVE_CONTRACT.set(None) # Spec, not the regex turn contract, decides the tools.
|
||||||
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
|
started = time.monotonic()
|
||||||
|
surface = spec_surface(disabled_tools)
|
||||||
|
by_name = {_name(s): s for s in surface}
|
||||||
|
user_text = next((_text(m.get('content')) for m in reversed(messages) if m.get('role') == 'user'), '')
|
||||||
|
history = list(messages)
|
||||||
|
security = ToolRunSecurityContext(
|
||||||
|
external_untrusted_context_seen=bool(external_untrusted_context_seen), unattended_tools=frozenset())
|
||||||
|
security.observe_messages(messages)
|
||||||
|
picks, executions, done_calls = [], [], set()
|
||||||
|
latest, first_token, usage_in, usage_out, rounds = '', None, 0, 0, 0
|
||||||
|
recovery = {}
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
|
||||||
|
for round_number in range(1, max_rounds + 1):
|
||||||
|
rounds = round_number
|
||||||
|
action = await asyncio.to_thread(
|
||||||
|
spec_router.next_action, user_text, latest, surface, messages[:-1], disabled_tools)
|
||||||
|
if action is None:
|
||||||
|
action = {'kind': 'answer_directly', 'name': 'router_unavailable', 'p': None}
|
||||||
|
if round_number == max_rounds and action['kind'] == 'tool':
|
||||||
|
action = {'kind': 'answer_directly', 'name': 'round_limit', 'p': None}
|
||||||
|
picks.append({'round': round_number, **action})
|
||||||
|
logger.info('[spec-driver] round=%s pick=%s p=%s', round_number, action['name'], action.get('p'))
|
||||||
|
request = {'model': model, 'messages': history, 'max_tokens': max_tokens,
|
||||||
|
'temperature': temperature, 'stream': True,
|
||||||
|
'stream_options': {'include_usage': True},
|
||||||
|
'chat_template_kwargs': {'enable_thinking': False}}
|
||||||
|
|
||||||
|
if action['kind'] == 'answer_directly':
|
||||||
|
answer_parts = []
|
||||||
|
async with preview_model_response(client, endpoint_url, headers, request, recovery) as response:
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.startswith('data: ') or line.strip() == 'data: [DONE]':
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chunk = json.loads(line[6:])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if chunk.get('error') or chunk.get('object') == 'error':
|
||||||
|
raise RuntimeError(f"model stream error: {str(chunk.get('error') or chunk)[:500]}")
|
||||||
|
u = chunk.get('usage') or {}
|
||||||
|
usage_in += u.get('prompt_tokens') or 0
|
||||||
|
usage_out += u.get('completion_tokens') or 0
|
||||||
|
for choice in chunk.get('choices') or []:
|
||||||
|
text = (choice.get('delta') or {}).get('content') or ''
|
||||||
|
if text:
|
||||||
|
first_token = first_token or time.monotonic()
|
||||||
|
answer_parts.append(text)
|
||||||
|
yield event({'delta': text})
|
||||||
|
answer = ''.join(answer_parts).strip()
|
||||||
|
if not answer:
|
||||||
|
yield event({'delta': 'The model returned no answer.'})
|
||||||
|
history.append({'role': 'assistant', 'content': answer})
|
||||||
|
break
|
||||||
|
|
||||||
|
name = 'ask_user' if action['kind'] == 'ask_user' else action['name']
|
||||||
|
# ODYSSEUS_SPEC_DRIVER_TOPK=k>1: offer Spec's top-k tools and let the chat model choose among them.
|
||||||
|
topk = int(os.getenv('ODYSSEUS_SPEC_DRIVER_TOPK', '1') or 1)
|
||||||
|
options = [name]
|
||||||
|
if action['kind'] == 'tool' and topk > 1:
|
||||||
|
options = [n for n, _ in action.get('top') or []][:topk] or [name]
|
||||||
|
schemas = [by_name.get(n) or next((s for s in FUNCTION_TOOL_SCHEMAS if _name(s) == n), None)
|
||||||
|
for n in options]
|
||||||
|
options = [n for n, sc in zip(options, schemas) if sc is not None]
|
||||||
|
schemas = [sc for sc in schemas if sc is not None]
|
||||||
|
if not schemas:
|
||||||
|
logger.info('[spec-driver] pick %s has no schema; answering instead', name)
|
||||||
|
latest = f'Tool {name} is not available.'
|
||||||
|
continue
|
||||||
|
request['tools'] = [grammar_safe(sc) for sc in schemas]
|
||||||
|
request['tool_choice'] = ({'type': 'function', 'function': {'name': options[0]}}
|
||||||
|
if len(options) == 1 else 'required')
|
||||||
|
if len(options) > 1:
|
||||||
|
logger.info('[spec-driver] round=%s options=%s', round_number, options)
|
||||||
|
try:
|
||||||
|
content, calls, usage = await _model_round(client, endpoint_url, headers, request, recovery)
|
||||||
|
except (RuntimeError, httpx.HTTPError) as exc:
|
||||||
|
logger.warning('[spec-driver] round=%s %s call failed: %s', round_number, name, exc)
|
||||||
|
latest = f'The {name} call could not be produced: {exc}'
|
||||||
|
continue
|
||||||
|
first_token = first_token or time.monotonic()
|
||||||
|
usage_in += usage.get('prompt_tokens') or 0
|
||||||
|
usage_out += usage.get('completion_tokens') or 0
|
||||||
|
call = next((c for c in calls if c['name'] in options), None)
|
||||||
|
if call is not None and len(options) > 1:
|
||||||
|
name = call['name']
|
||||||
|
logger.info('[spec-driver] round=%s model chose=%s rank=%s of %s',
|
||||||
|
round_number, name, options.index(name) + 1, options)
|
||||||
|
picks[-1]['chosen'] = name
|
||||||
|
if call is None:
|
||||||
|
latest = f'The {name} call was not produced.'
|
||||||
|
logger.info('[spec-driver] round=%s no %s call from the model', round_number, name)
|
||||||
|
continue
|
||||||
|
arguments = call['arguments'] or '{}'
|
||||||
|
signature = (name, arguments)
|
||||||
|
call_id = call['id'] or f'call_{uuid.uuid4().hex[:12]}'
|
||||||
|
history.append({'role': 'assistant', 'content': content or '',
|
||||||
|
'tool_calls': [{'id': call_id, 'type': 'function',
|
||||||
|
'function': {'name': name, 'arguments': arguments}}]})
|
||||||
|
if signature in done_calls:
|
||||||
|
output = 'This exact call already succeeded; its result is above. Do not repeat it.'
|
||||||
|
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||||
|
latest = output
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
args = json.loads(arguments)
|
||||||
|
except ValueError:
|
||||||
|
args = {}
|
||||||
|
block = function_call_to_tool_block(name, arguments)
|
||||||
|
yield event({'type': 'tool_start', 'tool': name, 'command': arguments,
|
||||||
|
'full_command': arguments, 'round': round_number})
|
||||||
|
tool_started = time.monotonic()
|
||||||
|
if block is None:
|
||||||
|
desc, result = f'{name}: invalid arguments', {
|
||||||
|
'error': 'Tool arguments could not be converted for execution.', 'exit_code': 1}
|
||||||
|
else:
|
||||||
|
desc, result = await execute_tool_block(
|
||||||
|
block, session_id=session_id, owner=owner, disabled_tools=disabled_tools,
|
||||||
|
tool_policy=tool_policy, security_context=security,
|
||||||
|
active_document_id=getattr(active_document, 'id', None), workspace=workspace,
|
||||||
|
client_runtime_context=client_runtime_context, request_authority=request_authority)
|
||||||
|
failed = bool(result.get('error')) or result.get('exit_code') not in (None, 0)
|
||||||
|
output = preview_tool_result_text(result, name, args)
|
||||||
|
tool_event = {'type': 'tool_output', 'tool': name, 'command': arguments, 'output': output,
|
||||||
|
'exit_code': result.get('exit_code', 1 if failed else 0), 'error': failed,
|
||||||
|
'execution_attempted': block is not None, 'blocked': bool(result.get('blocked')),
|
||||||
|
'desc': desc, 'round': round_number,
|
||||||
|
'seconds': round(time.monotonic() - tool_started, 3)}
|
||||||
|
if result.get('image_url'):
|
||||||
|
yield event({'type': 'generated_image', 'url': result['image_url'],
|
||||||
|
**{k: result[k] for k in ('image_url', 'image_id', 'image_prompt') if k in result}})
|
||||||
|
if result.get('ui_event'):
|
||||||
|
yield event({'type': 'ui_control', 'data': result})
|
||||||
|
executions.append(tool_event)
|
||||||
|
yield event(tool_event)
|
||||||
|
history.append({'role': 'tool', 'tool_call_id': call_id, 'content': output})
|
||||||
|
if not failed:
|
||||||
|
done_calls.add(signature)
|
||||||
|
latest = output
|
||||||
|
ask = result.get('ask_user')
|
||||||
|
if isinstance(ask, dict) and str(ask.get('question') or '').strip():
|
||||||
|
question = str(ask['question']).strip()
|
||||||
|
history.append({'role': 'assistant', 'content': question})
|
||||||
|
yield event({'type': 'final_response', 'content': question})
|
||||||
|
yield event({'type': 'ask_user', 'data': ask})
|
||||||
|
break
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
yield event({'type': 'metrics', 'data': {
|
||||||
|
'model': model, 'input_tokens': usage_in, 'output_tokens': usage_out,
|
||||||
|
'total_tokens': usage_in + usage_out, 'response_time': round(elapsed, 3),
|
||||||
|
'time_to_first_token': round(first_token - started, 3) if first_token else None,
|
||||||
|
'tokens_per_second': round(usage_out / elapsed, 2) if elapsed > 0 else 0,
|
||||||
|
'agent_rounds': rounds, 'tool_calls': len(executions), 'tool_events': executions,
|
||||||
|
'spec_driver_picks': picks, 'tool_schema_count': len(surface),
|
||||||
|
'schema_mode': 'spec_driver', 'temperature': temperature, 'max_output_tokens': max_tokens,
|
||||||
|
}})
|
||||||
yield 'data: [DONE]\n\n'
|
yield 'data: [DONE]\n\n'
|
||||||
return
|
|
||||||
|
|||||||
+9
-274
@@ -1,9 +1,9 @@
|
|||||||
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
"""Odysseus spec: small-model tool router (JEFF-style) for the agent harness.
|
||||||
|
|
||||||
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
The router (a 0.8B decision model served by jeff-serve) reads the user's
|
||||||
message and scores offered tools in one forward pass. The harness may narrow
|
message and scores every offered tool in one forward pass. The harness only
|
||||||
the existing turn contract, but cannot add authority or drop its obligations.
|
ever ADDS tools from its answer, never removes any, and fails open: on any
|
||||||
On an error, timeout or low-confidence answer it retains the existing contract.
|
error, timeout or low-confidence answer the compact tool set is unchanged.
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
ODYSSEUS_SPEC_ROUTER_URL jeff-serve base URL, e.g. http://ajax:8765 (unset = disabled)
|
||||||
@@ -17,17 +17,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import asyncio
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from contextvars import ContextVar
|
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_HEAD = {
|
_HEAD = {
|
||||||
@@ -40,16 +35,8 @@ _INSTRUCTIONS = (
|
|||||||
"If a tool is needed but information it requires is missing, choose ask the user."
|
"If a tool is needed but information it requires is missing, choose ask the user."
|
||||||
)
|
)
|
||||||
_AGENT = "Odysseus, a personal tool-using assistant."
|
_AGENT = "Odysseus, a personal tool-using assistant."
|
||||||
_LAST_DECISION: ContextVar[Optional[Dict[str, Any]]] = ContextVar('spec_router_decision', default=None)
|
# Recent decisions, for debugging in the UI/logs: most recent last.
|
||||||
|
LAST_DECISIONS: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
def latest_decision() -> Optional[Dict[str, Any]]:
|
|
||||||
"""Return the router decision in the current request context."""
|
|
||||||
return _LAST_DECISION.get()
|
|
||||||
|
|
||||||
|
|
||||||
def clear_decision() -> None:
|
|
||||||
_LAST_DECISION.set(None)
|
|
||||||
_MCP_BROWSER_TOOLS = (
|
_MCP_BROWSER_TOOLS = (
|
||||||
"mcp__builtin_browser__browser_navigate",
|
"mcp__builtin_browser__browser_navigate",
|
||||||
"mcp__builtin_browser__browser_snapshot",
|
"mcp__builtin_browser__browser_snapshot",
|
||||||
@@ -118,7 +105,6 @@ def spec_route_tools(
|
|||||||
the router answered confidently (a tool pick, or answer_directly)."""
|
the router answered confidently (a tool pick, or answer_directly)."""
|
||||||
added: Set[str] = set(prior_tool_names(messages))
|
added: Set[str] = set(prior_tool_names(messages))
|
||||||
decided = False
|
decided = False
|
||||||
clear_decision()
|
|
||||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
||||||
if not url or not (user_message or "").strip():
|
if not url or not (user_message or "").strip():
|
||||||
return (added, False) if return_decision else added
|
return (added, False) if return_decision else added
|
||||||
@@ -157,15 +143,6 @@ def spec_route_tools(
|
|||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
probs = out.get("probabilities") or {}
|
probs = out.get("probabilities") or {}
|
||||||
choice = out.get("choice")
|
choice = out.get("choice")
|
||||||
if not isinstance(probs, dict) or choice not in criteria:
|
|
||||||
raise ValueError("invalid router choice")
|
|
||||||
for key, probability in probs.items():
|
|
||||||
if key not in criteria or isinstance(probability, bool):
|
|
||||||
raise ValueError("invalid router probability key")
|
|
||||||
number = float(probability)
|
|
||||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
|
||||||
raise ValueError("invalid router probability")
|
|
||||||
probs = {key: float(value) for key, value in probs.items()}
|
|
||||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
||||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
||||||
ranked = sorted(
|
ranked = sorted(
|
||||||
@@ -195,133 +172,11 @@ def spec_route_tools(
|
|||||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
||||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
||||||
except Exception as exc: # fail open
|
except Exception as exc: # fail open
|
||||||
added = set(prior_tool_names(messages))
|
|
||||||
decided = False
|
|
||||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
||||||
logger.warning("spec router failed open: %s", exc)
|
logger.warning("spec router failed open: %s", exc)
|
||||||
|
LAST_DECISIONS.append(decision)
|
||||||
|
del LAST_DECISIONS[:-50]
|
||||||
decision["decided"] = decided
|
decision["decided"] = decided
|
||||||
_LAST_DECISION.set(decision)
|
|
||||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
|
||||||
return (added, decided) if return_decision else added
|
|
||||||
|
|
||||||
|
|
||||||
_ORIGINAL_SPEC_ROUTE_TOOLS = spec_route_tools
|
|
||||||
|
|
||||||
|
|
||||||
async def spec_route_tools_async(
|
|
||||||
user_message: str,
|
|
||||||
schemas: Iterable[Dict[str, Any]],
|
|
||||||
disabled_tools: Optional[Set[str]] = None,
|
|
||||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
|
||||||
return_decision: bool = False,
|
|
||||||
):
|
|
||||||
"""Asynchronous non-blocking tool routing.
|
|
||||||
|
|
||||||
Preserves request-local ContextVar semantics, decision propagation,
|
|
||||||
cancellation behavior, timeout limits, and safe fallback on router failure.
|
|
||||||
"""
|
|
||||||
if spec_route_tools is not _ORIGINAL_SPEC_ROUTE_TOOLS:
|
|
||||||
return spec_route_tools(
|
|
||||||
user_message,
|
|
||||||
schemas,
|
|
||||||
disabled_tools=disabled_tools,
|
|
||||||
messages=messages,
|
|
||||||
return_decision=return_decision,
|
|
||||||
)
|
|
||||||
|
|
||||||
added: Set[str] = set(prior_tool_names(messages))
|
|
||||||
decided = False
|
|
||||||
clear_decision()
|
|
||||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
|
||||||
if not url or not (user_message or "").strip():
|
|
||||||
return (added, False) if return_decision else added
|
|
||||||
t0 = time.time()
|
|
||||||
decision: Dict[str, Any] = {"ts": time.time(), "message": user_message[:160], "sticky": sorted(added)}
|
|
||||||
try:
|
|
||||||
disabled = set(disabled_tools or ())
|
|
||||||
lines: Dict[str, str] = {}
|
|
||||||
for s in schemas:
|
|
||||||
n = str(_fn(s).get("name") or "")
|
|
||||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
|
||||||
lines[n] = tool_line(s)
|
|
||||||
names = sorted(lines)
|
|
||||||
criteria = dict(_HEAD)
|
|
||||||
for i, n in enumerate(names, 1):
|
|
||||||
criteria[f"t{i}"] = lines[n]
|
|
||||||
req = {
|
|
||||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60"),
|
|
||||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": user_message},
|
|
||||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
|
||||||
}
|
|
||||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "0.5"))
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
||||||
while True:
|
|
||||||
remaining = max(0.2, deadline - time.time())
|
|
||||||
try:
|
|
||||||
resp = await client.post(
|
|
||||||
url + "/v1/systemone",
|
|
||||||
json=req,
|
|
||||||
headers={"content-type": "application/json"},
|
|
||||||
timeout=remaining,
|
|
||||||
)
|
|
||||||
if resp.status_code == 529:
|
|
||||||
if time.time() + 0.15 >= deadline:
|
|
||||||
resp.raise_for_status()
|
|
||||||
decision["retries"] = decision.get("retries", 0) + 1
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
continue
|
|
||||||
resp.raise_for_status()
|
|
||||||
out = resp.json()["answers"]["tool"]
|
|
||||||
break
|
|
||||||
except httpx.HTTPStatusError as busy:
|
|
||||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
|
||||||
raise
|
|
||||||
decision["retries"] = decision.get("retries", 0) + 1
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
probs = out.get("probabilities") or {}
|
|
||||||
choice = out.get("choice")
|
|
||||||
if not isinstance(probs, dict) or choice not in criteria:
|
|
||||||
raise ValueError("invalid router choice")
|
|
||||||
for key, probability in probs.items():
|
|
||||||
if key not in criteria or isinstance(probability, bool):
|
|
||||||
raise ValueError("invalid router probability key")
|
|
||||||
number = float(probability)
|
|
||||||
if not math.isfinite(number) or not 0 <= number <= 1:
|
|
||||||
raise ValueError("invalid router probability")
|
|
||||||
probs = {key: float(value) for key, value in probs.items()}
|
|
||||||
min_conf = float(os.getenv("ODYSSEUS_SPEC_ROUTER_MIN_CONF", "0.3"))
|
|
||||||
second_min = float(os.getenv("ODYSSEUS_SPEC_ROUTER_SECOND", "0.25"))
|
|
||||||
ranked = sorted(
|
|
||||||
((p, k) for k, p in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True
|
|
||||||
)
|
|
||||||
picks: List[str] = []
|
|
||||||
if choice and choice.startswith("t") and choice[1:].isdigit() and float(probs.get(choice, 0.0)) >= min_conf:
|
|
||||||
picks.append(names[int(choice[1:]) - 1])
|
|
||||||
for p, k in ranked[:3]:
|
|
||||||
nm = names[int(k[1:]) - 1]
|
|
||||||
if nm not in picks and p >= second_min:
|
|
||||||
picks.append(nm)
|
|
||||||
_bmode = os.getenv("ODYSSEUS_SPEC_BROWSER_FALLBACK", "0")
|
|
||||||
if _bmode != "0" and "private_browser" in picks:
|
|
||||||
picks = [n for n in picks if n != "private_browser"]
|
|
||||||
extra = list(_MCP_BROWSER_TOOLS) if _bmode == "mcp" else []
|
|
||||||
for n in extra + ["web_search", "web_fetch"]:
|
|
||||||
if n not in picks:
|
|
||||||
picks.append(n)
|
|
||||||
added.update(picks)
|
|
||||||
decided = bool(picks) or (choice == "answer_directly" and float(probs.get("answer_directly", 0.0)) >= min_conf)
|
|
||||||
decision.update(choice=choice, tools=picks, ms=round((time.time() - t0) * 1000, 1),
|
|
||||||
top=[(names[int(k[1:]) - 1], round(p, 3)) for p, k in ranked[:3]],
|
|
||||||
answer_directly=round(float(probs.get("answer_directly", 0.0)), 3),
|
|
||||||
ask_user=round(float(probs.get("ask_user", 0.0)), 3))
|
|
||||||
except Exception as exc: # fail open
|
|
||||||
added = set(prior_tool_names(messages))
|
|
||||||
decided = False
|
|
||||||
decision.update(error=str(exc)[:200], ms=round((time.time() - t0) * 1000, 1))
|
|
||||||
logger.warning("spec router failed open: %s", exc)
|
|
||||||
decision["decided"] = decided
|
|
||||||
_LAST_DECISION.set(decision)
|
|
||||||
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
logger.info("spec router: %s", {key: value for key, value in decision.items() if key != "message"})
|
||||||
return (added, decided) if return_decision else added
|
return (added, decided) if return_decision else added
|
||||||
|
|
||||||
@@ -473,59 +328,13 @@ def route_thinking(user_message: str) -> Optional[Dict[str, Any]]:
|
|||||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
||||||
if kind5:
|
if kind5:
|
||||||
out["kind"] = ans.get("choice")
|
out["kind"] = ans.get("choice")
|
||||||
logger.info("spec think router: %s", out)
|
logger.info("spec think router: %s msg=%r", out, text[:120])
|
||||||
return out
|
return out
|
||||||
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
except Exception as exc: # fail open: the existing progressive-thinking rules still apply
|
||||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_ORIGINAL_ROUTE_THINKING = route_thinking
|
|
||||||
|
|
||||||
|
|
||||||
async def route_thinking_async(user_message: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Asynchronous non-blocking route_thinking."""
|
|
||||||
if route_thinking is not _ORIGINAL_ROUTE_THINKING:
|
|
||||||
return route_thinking(user_message)
|
|
||||||
if not think_router_enabled():
|
|
||||||
return None
|
|
||||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
|
||||||
text = (user_message or "").strip()
|
|
||||||
if not url or not text:
|
|
||||||
return None
|
|
||||||
kind5 = os.getenv("ODYSSEUS_SPEC_THINK_QUESTION", "binary").strip().lower() == "kind5"
|
|
||||||
question = _KIND5_QUESTION if kind5 else {"type": "choice", "instructions": _THINK_INSTRUCTIONS,
|
|
||||||
"criteria": {"think": _THINK_DEF, "direct": _DIRECT_DEF}}
|
|
||||||
req = {
|
|
||||||
"model": os.getenv("ODYSSEUS_SPEC_THINK_MODEL", os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-s60")),
|
|
||||||
"state": {"agent": _AGENT, "conversation": [], "user_message": text[:4000]},
|
|
||||||
"questions": {"thinking": question},
|
|
||||||
}
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
url + "/v1/systemone",
|
|
||||||
json=req,
|
|
||||||
headers={"content-type": "application/json"},
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
ans = resp.json()["answers"]["thinking"]
|
|
||||||
probs = ans.get("probabilities") or {}
|
|
||||||
p = float(1.0 - probs.get("direct", 1.0)) if kind5 else float(probs.get("think", 0.0))
|
|
||||||
min_p = float(os.getenv("ODYSSEUS_SPEC_THINK_MIN_P", "0.5"))
|
|
||||||
out = {"think": p >= min_p, "p_think": round(p, 4), "ms": round((time.time() - t0) * 1000, 1)}
|
|
||||||
if kind5:
|
|
||||||
out["kind"] = ans.get("choice")
|
|
||||||
logger.info("spec think router: %s", out)
|
|
||||||
return out
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("spec think router failed (%s); falling back to rule-based thinking", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
# ---- Ajax Spec driver: the router picks every step --------------------------------------------------------
|
||||||
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
||||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
||||||
@@ -580,81 +389,7 @@ def next_action(user_message: str, latest_result: str, schemas: Iterable[Dict[st
|
|||||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
||||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
||||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
||||||
logger.info("spec next action: %s", action)
|
logger.info("spec next action: %s msg=%r", action, (user_message or "")[:80])
|
||||||
return action
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("spec next action failed: %s", exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
_ORIGINAL_NEXT_ACTION = next_action
|
|
||||||
|
|
||||||
|
|
||||||
async def next_action_async(user_message: str, latest_result: str, schemas: Iterable[Dict[str, Any]],
|
|
||||||
messages: Optional[Iterable[Dict[str, Any]]] = None,
|
|
||||||
disabled_tools: Optional[Set[str]] = None) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Asynchronous non-blocking next_action."""
|
|
||||||
if next_action is not _ORIGINAL_NEXT_ACTION:
|
|
||||||
return next_action(user_message, latest_result, schemas, messages=messages, disabled_tools=disabled_tools)
|
|
||||||
url = os.getenv("ODYSSEUS_SPEC_ROUTER_URL", "").strip().rstrip("/")
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
t0 = time.time()
|
|
||||||
disabled = set(disabled_tools or ())
|
|
||||||
lines: Dict[str, str] = {}
|
|
||||||
for s in schemas:
|
|
||||||
n = str(_fn(s).get("name") or "")
|
|
||||||
if n and n not in disabled and n != "ask_user" and n not in lines:
|
|
||||||
lines[n] = tool_line(s)
|
|
||||||
names = sorted(lines)
|
|
||||||
criteria = dict(_HEAD)
|
|
||||||
for i, n in enumerate(names, 1):
|
|
||||||
criteria[f"t{i}"] = lines[n]
|
|
||||||
um = (user_message or "")[-3000:]
|
|
||||||
if latest_result:
|
|
||||||
um += "\n\nLatest tool result:\n" + latest_result[-1500:]
|
|
||||||
req = {
|
|
||||||
"model": os.getenv("ODYSSEUS_SPEC_ROUTER_MODEL", "odysseus-agentic-v4"),
|
|
||||||
"state": {"agent": _AGENT, "conversation": _conversation(messages), "user_message": um},
|
|
||||||
"questions": {"tool": {"type": "choice", "instructions": _INSTRUCTIONS, "criteria": criteria}},
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
timeout = float(os.getenv("ODYSSEUS_SPEC_ROUTER_TIMEOUT", "3.0"))
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
||||||
while True:
|
|
||||||
remaining = max(0.2, deadline - time.time())
|
|
||||||
try:
|
|
||||||
resp = await client.post(
|
|
||||||
url + "/v1/systemone",
|
|
||||||
json=req,
|
|
||||||
headers={"content-type": "application/json"},
|
|
||||||
timeout=remaining,
|
|
||||||
)
|
|
||||||
if resp.status_code == 529:
|
|
||||||
if time.time() + 0.15 >= deadline:
|
|
||||||
resp.raise_for_status()
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
continue
|
|
||||||
resp.raise_for_status()
|
|
||||||
out = resp.json()["answers"]["tool"]
|
|
||||||
break
|
|
||||||
except httpx.HTTPStatusError as busy:
|
|
||||||
if busy.response.status_code != 529 or time.time() + 0.15 >= deadline:
|
|
||||||
raise
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
probs = out.get("probabilities") or {}
|
|
||||||
choice = str(out.get("choice") or "")
|
|
||||||
if choice.startswith("t") and choice[1:].isdigit():
|
|
||||||
action = {"kind": "tool", "name": names[int(choice[1:]) - 1]}
|
|
||||||
elif choice in ("answer_directly", "ask_user"):
|
|
||||||
action = {"kind": choice, "name": choice}
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
ranked = sorted(((float(v), k) for k, v in probs.items() if k.startswith("t") and k[1:].isdigit()), reverse=True)
|
|
||||||
action.update(p=round(float(probs.get(choice, 0.0)), 3), ms=round((time.time() - t0) * 1000, 1),
|
|
||||||
top=[(names[int(k[1:]) - 1], round(v, 3)) for v, k in ranked[:6]])
|
|
||||||
logger.info("spec next action: %s", action)
|
|
||||||
return action
|
return action
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("spec next action failed: %s", exc)
|
logger.warning("spec next action failed: %s", exc)
|
||||||
|
|||||||
+12
-10
@@ -47,23 +47,25 @@ def resolve_task_candidates(
|
|||||||
candidates.append((url, model, headers or {}))
|
candidates.append((url, model, headers or {}))
|
||||||
|
|
||||||
if override_url and override_model:
|
if override_url and override_model:
|
||||||
|
headers = override_headers or {}
|
||||||
try:
|
try:
|
||||||
from src.database import SessionLocal
|
from src.database import ModelEndpoint, SessionLocal
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import normalize_base, resolve_endpoint_runtime, build_headers
|
||||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
|
||||||
resolve_owner_registered_endpoint,
|
|
||||||
)
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ep = resolve_owner_registered_endpoint(db, override_url, owner)
|
from src.auth_helpers import owner_filter
|
||||||
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||||
_append(build_chat_url(runtime_base), override_model, build_headers(api_key, runtime_base))
|
for ep in owner_filter(query, ModelEndpoint, owner).all():
|
||||||
|
base = normalize_base(getattr(ep, "base_url", "") or "")
|
||||||
|
if _same_endpoint_base(override_url, base):
|
||||||
|
runtime_base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||||
|
headers = build_headers(api_key, runtime_base or base)
|
||||||
|
break
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
# A missing, foreign, disabled or unreadable registration is not
|
|
||||||
# authority to contact a caller-provided endpoint or use its key.
|
|
||||||
pass
|
pass
|
||||||
|
_append(override_url, override_model, headers)
|
||||||
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
_append(*resolve_task_endpoint(fallback_url, fallback_model, fallback_headers, owner=owner))
|
||||||
_append(*resolve_endpoint("utility", owner=owner))
|
_append(*resolve_endpoint("utility", owner=owner))
|
||||||
_append(*resolve_endpoint("default", owner=owner))
|
_append(*resolve_endpoint("default", owner=owner))
|
||||||
|
|||||||
+25
-50
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Tuple
|
||||||
|
|
||||||
from core.auth import RESERVED_USERNAMES
|
from core.auth import RESERVED_USERNAMES
|
||||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS, auth_disabled
|
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||||
from src.task_action_policy import (
|
from src.task_action_policy import (
|
||||||
is_admin_only_task_action,
|
is_admin_only_task_action,
|
||||||
owner_has_admin_task_privileges,
|
owner_has_admin_task_privileges,
|
||||||
@@ -21,15 +21,6 @@ from src.task_action_policy import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TaskEndpointAuthorityError(RuntimeError):
|
|
||||||
"""A scheduled task's endpoint or its credentials cannot be authorized."""
|
|
||||||
|
|
||||||
|
|
||||||
def _require_task_owner(owner):
|
|
||||||
if not str(owner or "").strip() and not auth_disabled():
|
|
||||||
raise TaskEndpointAuthorityError("Scheduled task has no owner in authenticated mode")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
def _is_sft_fixture_owner(owner: str | None) -> bool:
|
||||||
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
"""Synthetic SFT accounts may manage tasks but must never auto-fire them."""
|
||||||
return str(owner or "").strip().lower().startswith("sft_")
|
return str(owner or "").strip().lower().startswith("sft_")
|
||||||
@@ -1354,7 +1345,6 @@ class TaskScheduler:
|
|||||||
|
|
||||||
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
async def _execute_action(self, task, run_id: str | None = None) -> tuple:
|
||||||
"""Execute a built-in action (no LLM needed)."""
|
"""Execute a built-in action (no LLM needed)."""
|
||||||
_require_task_owner(task.owner)
|
|
||||||
from src.builtin_actions import BUILTIN_ACTIONS
|
from src.builtin_actions import BUILTIN_ACTIONS
|
||||||
from src.agent_runtime.authority import (
|
from src.agent_runtime.authority import (
|
||||||
bind_request_authority, restore_task_authority, task_operation,
|
bind_request_authority, restore_task_authority, task_operation,
|
||||||
@@ -1644,7 +1634,6 @@ class TaskScheduler:
|
|||||||
async def _execute_llm_task(self, task, db) -> str:
|
async def _execute_llm_task(self, task, db) -> str:
|
||||||
"""Execute an LLM task with full tool access via the agent loop."""
|
"""Execute an LLM task with full tool access via the agent loop."""
|
||||||
from core.database import Session as DbSession, ChatMessage, CrewMember
|
from core.database import Session as DbSession, ChatMessage, CrewMember
|
||||||
_require_task_owner(task.owner)
|
|
||||||
|
|
||||||
# If this task is wired to a CrewMember (personal assistant, custom
|
# If this task is wired to a CrewMember (personal assistant, custom
|
||||||
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
# crew), prefer the crew member's persona/model/endpoint as overrides.
|
||||||
@@ -1665,11 +1654,6 @@ class TaskScheduler:
|
|||||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise RuntimeError("No model/endpoint configured")
|
raise RuntimeError("No model/endpoint configured")
|
||||||
# Authorize the original URL before normalization can erase a query,
|
|
||||||
# fragment, or caller-selected path. Stored tasks do not themselves
|
|
||||||
# grant permission to contact an endpoint.
|
|
||||||
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
|
||||||
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
|
||||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
# Record the resolved model so _execute_task_locked can persist it on
|
# Record the resolved model so _execute_task_locked can persist it on
|
||||||
# the run (tasks rarely pin a model, so this is the only record of
|
# the run (tasks rarely pin a model, so this is the only record of
|
||||||
@@ -1788,8 +1772,6 @@ class TaskScheduler:
|
|||||||
relevant_tools=relevant_tools,
|
relevant_tools=relevant_tools,
|
||||||
datetime_context_msg=_dt_msg,
|
datetime_context_msg=_dt_msg,
|
||||||
)
|
)
|
||||||
except TaskEndpointAuthorityError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
logger.warning(f"Agent loop failed for task '{task.name}', falling back to simple call: {e}")
|
||||||
from src.task_endpoint import task_llm_call_async
|
from src.task_endpoint import task_llm_call_async
|
||||||
@@ -2004,7 +1986,6 @@ class TaskScheduler:
|
|||||||
override_user_message: str | None = None,
|
override_user_message: str | None = None,
|
||||||
datetime_context_msg: dict | None = None) -> str:
|
datetime_context_msg: dict | None = None) -> str:
|
||||||
"""Run the full agent loop with tool access, collecting the final text."""
|
"""Run the full agent loop with tool access, collecting the final text."""
|
||||||
_require_task_owner(task.owner)
|
|
||||||
from src.agent_loop import stream_agent_loop
|
from src.agent_loop import stream_agent_loop
|
||||||
from src.agent_runtime.authority import restore_task_authority
|
from src.agent_runtime.authority import restore_task_authority
|
||||||
|
|
||||||
@@ -2034,8 +2015,8 @@ class TaskScheduler:
|
|||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
finally:
|
finally:
|
||||||
db2.close()
|
db2.close()
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
raise TaskEndpointAuthorityError("Scheduled task endpoint is unavailable or unauthorized") from exc
|
pass
|
||||||
full_text = ""
|
full_text = ""
|
||||||
final_text_replaced = False
|
final_text_replaced = False
|
||||||
tool_results = []
|
tool_results = []
|
||||||
@@ -2191,16 +2172,11 @@ class TaskScheduler:
|
|||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
|
|
||||||
# Resolve endpoint/model: research settings > task settings > session defaults
|
# Resolve endpoint/model: research settings > task settings > session defaults
|
||||||
_require_task_owner(task.owner)
|
|
||||||
endpoint_url = task.endpoint_url
|
endpoint_url = task.endpoint_url
|
||||||
model = task.model
|
model = task.model
|
||||||
headers = {}
|
headers = {}
|
||||||
headers_from_resolver = False
|
headers_from_resolver = False
|
||||||
|
|
||||||
if endpoint_url:
|
|
||||||
from src.endpoint_resolver import resolve_owner_registered_endpoint
|
|
||||||
resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
@@ -2237,8 +2213,8 @@ class TaskScheduler:
|
|||||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||||
endpoint_url = build_chat_url(base)
|
endpoint_url = build_chat_url(base)
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
raise TaskEndpointAuthorityError("Scheduled research endpoint is unavailable or unauthorized") from exc
|
pass
|
||||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||||
|
|
||||||
max_tokens, extraction_timeout, extraction_concurrency = (
|
max_tokens, extraction_timeout, extraction_concurrency = (
|
||||||
@@ -2372,11 +2348,11 @@ class TaskScheduler:
|
|||||||
have to special-case each tool's schema; the MCP tool ignores keys it
|
have to special-case each tool's schema; the MCP tool ignores keys it
|
||||||
doesn't recognise.
|
doesn't recognise.
|
||||||
"""
|
"""
|
||||||
_require_task_owner(task.owner)
|
|
||||||
from src.tool_utils import get_mcp_manager
|
from src.tool_utils import get_mcp_manager
|
||||||
mcp = get_mcp_manager()
|
mcp = get_mcp_manager()
|
||||||
if not mcp:
|
if not mcp:
|
||||||
raise RuntimeError("MCP manager not available for task delivery")
|
logger.warning(f"Task {task.id}: MCP manager not available for delivery")
|
||||||
|
return
|
||||||
|
|
||||||
# Resolve recipient — prefer the configured email From (the established
|
# Resolve recipient — prefer the configured email From (the established
|
||||||
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
# "email yourself" pattern from daily_brief), fall back to task.owner.
|
||||||
@@ -2385,7 +2361,7 @@ class TaskScheduler:
|
|||||||
recipient = None
|
recipient = None
|
||||||
try:
|
try:
|
||||||
from routes.email_helpers import _get_email_config
|
from routes.email_helpers import _get_email_config
|
||||||
cfg = _get_email_config(owner=task.owner or "") or {}
|
cfg = _get_email_config() or {}
|
||||||
recipient = cfg.get("from_address") or None
|
recipient = cfg.get("from_address") or None
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
logger.debug(f"_deliver_via_mcp: email config lookup failed: {_e}")
|
||||||
@@ -2401,14 +2377,6 @@ class TaskScheduler:
|
|||||||
"X-Odysseus-Ref": str(task.id),
|
"X-Odysseus-Ref": str(task.id),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if tool_name.startswith("mcp__email__"):
|
|
||||||
# The email server uses this reserved field for its account and
|
|
||||||
# approval scope; it must come from the task owner, never a model.
|
|
||||||
from src.tool_execution import _EMAIL_MCP_OWNER_ARG, _EMAIL_MCP_SESSION_ARG
|
|
||||||
if task.owner:
|
|
||||||
args[_EMAIL_MCP_OWNER_ARG] = task.owner
|
|
||||||
if getattr(task, "session_id", None):
|
|
||||||
args[_EMAIL_MCP_SESSION_ARG] = task.session_id
|
|
||||||
if recipient:
|
if recipient:
|
||||||
# Cover the common field names so we work across MCP servers (Gmail,
|
# Cover the common field names so we work across MCP servers (Gmail,
|
||||||
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
# generic SMTP, Slack DMs, etc.) without having to hard-code each.
|
||||||
@@ -2423,18 +2391,25 @@ class TaskScheduler:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
mcp_result = await mcp.call_tool(tool_name, args)
|
mcp_result = await mcp.call_tool(tool_name, args)
|
||||||
if isinstance(mcp_result, dict):
|
stderr = mcp_result.get("stderr", "")
|
||||||
from src.tool_execution import _normalize_mcp_text_error
|
stdout = mcp_result.get("stdout", "")
|
||||||
mcp_result = _normalize_mcp_text_error(dict(mcp_result))
|
body_len = len(result or "")
|
||||||
if not isinstance(mcp_result, dict) or mcp_result.get("error") or mcp_result.get("exit_code") != 0:
|
exit_code = mcp_result.get("exit_code", 0)
|
||||||
raise RuntimeError("MCP task delivery failed or returned an invalid result")
|
if exit_code != 0:
|
||||||
if tool_name == "mcp__email__send_email" and not str(mcp_result.get("stdout") or "").strip().startswith("Sent email to "):
|
logger.warning(
|
||||||
raise RuntimeError("MCP email delivery did not report a completed send")
|
f"Task {task.id} MCP delivery FAILED via {tool_name}: "
|
||||||
logger.info("Task %s delivered via MCP tool %s (recipient_set=%s, body=%sb)",
|
f"exit={exit_code} stderr={stderr[:400]!r} stdout={stdout[:400]!r}"
|
||||||
task.id, tool_name, bool(recipient), len(result or ""))
|
)
|
||||||
|
else:
|
||||||
|
# Include the MCP tool's own stdout (e.g. email_server returns
|
||||||
|
# "Sent email to ... with subject ...") + the body size so a
|
||||||
|
# silent SMTP failure is easier to spot in the logs.
|
||||||
|
logger.info(
|
||||||
|
f"Task {task.id} delivered via MCP tool {tool_name} "
|
||||||
|
f"(recipient_set={bool(recipient)}, body={body_len}b, reply={stdout[:200]!r})"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
logger.error(f"Task {task.id} MCP delivery failed: {e}")
|
||||||
raise
|
|
||||||
|
|
||||||
async def run_task_now(self, task_id: str, *, force: bool = False):
|
async def run_task_now(self, task_id: str, *, force: bool = False):
|
||||||
"""Manually trigger a task execution."""
|
"""Manually trigger a task execution."""
|
||||||
|
|||||||
@@ -227,16 +227,8 @@ _register(
|
|||||||
ToolEffect.READ_PRIVATE,
|
ToolEffect.READ_PRIVATE,
|
||||||
)
|
)
|
||||||
_register(
|
_register(
|
||||||
{"plan_edit"},
|
{"plan_edit", "run_editor_command"},
|
||||||
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(
|
||||||
{
|
{
|
||||||
@@ -410,7 +402,7 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
|
|||||||
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
|
"manage_endpoints": frozenset({"add", "delete", "enable", "disable"}),
|
||||||
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
|
"manage_mcp": frozenset({"add", "delete", "enable", "disable", "reconnect"}),
|
||||||
"manage_settings": frozenset(
|
"manage_settings": frozenset(
|
||||||
{"set", "delete", "reset", "disable_tool"}
|
{"set", "delete", "reset", "disable_tool", "enable_tool"}
|
||||||
),
|
),
|
||||||
"manage_tokens": frozenset({"create", "delete"}),
|
"manage_tokens": frozenset({"create", "delete"}),
|
||||||
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
|
"manage_webhooks": frozenset({"add", "delete", "enable", "disable"}),
|
||||||
|
|||||||
@@ -1559,7 +1559,7 @@ def _agent_subprocess_env() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _legacy_private_browser_enabled() -> bool:
|
def _legacy_private_browser_enabled() -> bool:
|
||||||
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}
|
return os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
async def _legacy_private_browser(operation, transport, *, progress_cb=None, session_id=None, owner=None):
|
||||||
@@ -1757,13 +1757,11 @@ async def execute_tool_block(
|
|||||||
|
|
||||||
transport = operation.transport_tool
|
transport = operation.transport_tool
|
||||||
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
if operation.tool == "private_browser" and _legacy_private_browser_enabled():
|
||||||
# The legacy process cannot establish the sealed browser/page identity
|
# Restored pre-Wave-3 private_browser (agent-browser CLI). The Wave 3
|
||||||
# required by the current runtime. Never execute it ahead of resource
|
# producer in src.browser_identity refuses every page action in this
|
||||||
# binding and the untrusted-context approval gate, even when opted in.
|
# build. This compatibility transport requires an explicit opt-in.
|
||||||
return f"{transport}: UNSUPPORTED", {
|
return await _legacy_private_browser(
|
||||||
**page_unavailable(),
|
operation, transport, progress_cb=progress_cb, session_id=session_id, owner=owner)
|
||||||
"error": "Legacy browser transport cannot establish the required browser authority.",
|
|
||||||
}
|
|
||||||
if operation.tool == "private_browser":
|
if operation.tool == "private_browser":
|
||||||
try:
|
try:
|
||||||
_, browser_args = parse_browser_operation(operation.input)
|
_, browser_args = parse_browser_operation(operation.input)
|
||||||
|
|||||||
+1
-1
@@ -145,7 +145,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
|||||||
"manage_tokens": "API token management: list, create, or delete API access tokens.",
|
"manage_tokens": "API token management: list, create, or delete API access tokens.",
|
||||||
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
||||||
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
|
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
|
||||||
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also turns tools off globally (disable_tool) and lists disabled ones (list_tools); re-enabling is only done by the user in Settings > Agent Tools. Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
|
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
|
||||||
"create_session": "Create a new chat with a name and model.",
|
"create_session": "Create a new chat with a name and model.",
|
||||||
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
"list_sessions": "List all chats with their metadata (the UI calls these 'chats'). Use for 'list my chats', 'rename all my chats' (list first, then manage_session to rename each).",
|
||||||
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
"send_to_session": "Send a message to another chat. Cross-chat communication.",
|
||||||
|
|||||||
+7
-7
@@ -1292,14 +1292,14 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "manage_settings",
|
"name": "manage_settings",
|
||||||
"description": "Manage user preferences and settings. Use `disable_tool`/`list_tools` to turn individual tools off globally or list what is disabled (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Disabled tools can only be re-enabled by the user in Settings > Agent Tools, never from chat. Use list/get/set/delete for free-form preferences.",
|
"description": "Manage user preferences and settings. Use `disable_tool`/`enable_tool`/`list_tools` to turn individual tools on or off globally (e.g. shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email). Use list/get/set/delete for free-form preferences.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "list_tools"]},
|
"action": {"type": "string", "enum": ["list", "get", "set", "delete", "disable_tool", "enable_tool", "list_tools"]},
|
||||||
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
|
"key": {"type": "string", "description": "Setting key (for get/set/delete)"},
|
||||||
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
|
"value": {"description": "Setting value (for set) — can be string, number, boolean, or object"},
|
||||||
"tool": {"type": "string", "description": "Tool name to disable (for disable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
|
"tool": {"type": "string", "description": "Tool name to disable/enable (for disable_tool/enable_tool). Accepts aliases: shell, search, browser, documents, memory, skills, images, tasks, notes, calendar, email — or a raw tool name like 'bash' or 'web_search'."}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
}
|
}
|
||||||
@@ -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 when no turn-bound session is available; the bound session takes precedence"},
|
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
|
||||||
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
"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 when no turn-bound session is available; the bound session takes precedence"}
|
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1583,7 +1583,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
|
"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 when no turn-bound session is available; the bound session takes precedence"}
|
"session": {"type": "string", "description": "Editor session id"}
|
||||||
},
|
},
|
||||||
"required": ["op"]
|
"required": ["op"]
|
||||||
}
|
}
|
||||||
@@ -2000,7 +2000,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"", "0", "false", "no", "off"}:
|
if os.getenv("ODYSSEUS_PRIVATE_BROWSER_LEGACY", "0").strip().lower() not in {"0", "false", "no", "off"}:
|
||||||
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
|
# Restored pre-Wave-3 private_browser (see src.tool_execution._legacy_private_browser).
|
||||||
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
|
from src.private_browser_legacy_schema import PRIVATE_BROWSER_SCHEMA as _PB_LEGACY
|
||||||
|
|
||||||
|
|||||||
@@ -769,8 +769,6 @@ _APP_API_BLOCKLIST_PREFIXES = (
|
|||||||
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
|
# {"tasks": []} to /api/cookbook/state, which overwrote the whole file.
|
||||||
# Use dedicated tools or UI flows instead.
|
# Use dedicated tools or UI flows instead.
|
||||||
_APP_API_BLOCKLIST_METHOD_PATH = (
|
_APP_API_BLOCKLIST_METHOD_PATH = (
|
||||||
("POST", "/api/tools"), # global tool policy is changed by a human in Settings
|
|
||||||
("PATCH", "/api/mcp/servers"), # per-server MCP tool policy and server toggles, same reason
|
|
||||||
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
|
("GET", "/api/email/accounts"), # owner-filtered in tool context; use list_email_accounts MCP tool
|
||||||
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
|
("POST", "/api/cookbook/state"), # whole-file overwrite — agent must use serve_preset/serve_model instead
|
||||||
("DELETE", "/api/cookbook/state"),
|
("DELETE", "/api/cookbook/state"),
|
||||||
@@ -895,10 +893,6 @@ async def do_app_api(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||||
return {"error": f"Unsupported method: {method}", "exit_code": 1}
|
return {"error": f"Unsupported method: {method}", "exit_code": 1}
|
||||||
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
|
if any(method == m and path.startswith(p) for m, p in _APP_API_BLOCKLIST_METHOD_PATH):
|
||||||
if path.startswith("/api/tools"):
|
|
||||||
return {"error": "Tool policy cannot be replaced from chat. Re-enable tools in Settings > Agent Tools; use manage_settings to list or disable them.", "exit_code": 1}
|
|
||||||
if path.startswith("/api/mcp/servers"):
|
|
||||||
return {"error": "MCP server and tool policy cannot be changed from chat. Ask the user to change it in Settings > Integrations.", "exit_code": 1}
|
|
||||||
if "/api/email/accounts" in path:
|
if "/api/email/accounts" in path:
|
||||||
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
|
return {"error": "Don't use /api/email/accounts via app_api — it is owner-filtered in tool context and may return empty. Use the `list_email_accounts` email tool, then pass `account` to list_emails/read_email.", "exit_code": 1}
|
||||||
if "/api/cookbook/packages/install" in path:
|
if "/api/cookbook/packages/install" in path:
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
width: 240px;
|
width: 240px;
|
||||||
background: var(--sidebar-bg, var(--panel));
|
background: var(--sidebar-bg, var(--panel));
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, visibility 0.25s;
|
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -361,23 +361,12 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
/* !important so it beats the inline width init.js restores from storage —
|
/* !important so it beats the inline width init.js restores from storage —
|
||||||
otherwise the width never changes and only opacity animates, making the
|
otherwise the width never changes and only opacity animates, making the
|
||||||
collapse look instant. With this, width animates from the inline value
|
collapse look instant. With this, width animates from the inline value
|
||||||
down to 0 via the .sidebar width transition.
|
down to 0 via the .sidebar width transition. */
|
||||||
display overrides the global .hidden utility, which would otherwise
|
|
||||||
remove the box before any transition can run. visibility (transitioned
|
|
||||||
on .sidebar) flips to hidden only once the collapse has finished,
|
|
||||||
taking the closed sidebar out of the tab order. */
|
|
||||||
display: flex;
|
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
border: none;
|
border: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
/* IDs so this also beats the mobile drawer's !important transition. */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
#sidebar, #icon-rail { transition: none !important; }
|
|
||||||
}
|
}
|
||||||
/* ===== Sidebar User Bar ===== */
|
/* ===== Sidebar User Bar ===== */
|
||||||
.sidebar-user-bar {
|
.sidebar-user-bar {
|
||||||
@@ -460,6 +449,10 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.sidebar.hidden .sidebar-user-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sticky sidebar header — logo, never scrolls */
|
/* Sticky sidebar header — logo, never scrolls */
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -680,24 +673,6 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
margin-bottom: calc(-1 * var(--safe-bottom));
|
margin-bottom: calc(-1 * var(--safe-bottom));
|
||||||
padding-bottom: calc(8px + var(--safe-bottom));
|
padding-bottom: calc(8px + var(--safe-bottom));
|
||||||
}
|
}
|
||||||
/* Tuck the rail under the sidebar (which paints above it) and slide it
|
|
||||||
out as the sidebar collapses, so the combined width eases instead of
|
|
||||||
jumping by the rail's 48px when the two swap. */
|
|
||||||
.icon-rail,
|
|
||||||
.icon-rail.right-side {
|
|
||||||
margin-inline-end: -48px;
|
|
||||||
transition: margin-inline-end 0.25s ease, display 0.25s allow-discrete;
|
|
||||||
}
|
|
||||||
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
|
|
||||||
html.ody-sidebar-mini .icon-rail {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
}
|
|
||||||
@starting-style {
|
|
||||||
body.sidebar-collapsed:not(.hamburger-only) .icon-rail,
|
|
||||||
html.ody-sidebar-mini .icon-rail {
|
|
||||||
margin-inline-end: -48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.icon-rail-divider {
|
.icon-rail-divider {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -5141,7 +5116,7 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
width: 80% !important;
|
width: 80% !important;
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
|
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
|
||||||
transition: transform 0.25s ease, opacity 0.25s ease, visibility 0.25s !important;
|
transition: transform 0.25s ease, opacity 0.25s ease !important;
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
/* Force opaque background — Firefox mobile renders the
|
/* Force opaque background — Firefox mobile renders the
|
||||||
@@ -5172,7 +5147,6 @@ body.bg-pattern-ascii-fireflies {
|
|||||||
}
|
}
|
||||||
html.ody-sidebar-off .sidebar,
|
html.ody-sidebar-off .sidebar,
|
||||||
html.ody-mobile-startup-sidebar-hidden .sidebar {
|
html.ody-mobile-startup-sidebar-hidden .sidebar {
|
||||||
width: 80% !important;
|
|
||||||
transform: translateX(-100%) !important;
|
transform: translateX(-100%) !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
|
|||||||
@@ -90,6 +90,12 @@
|
|||||||
.lib-tabs,
|
.lib-tabs,
|
||||||
.gallery-tabs,
|
.gallery-tabs,
|
||||||
.preset-tabs {
|
.preset-tabs {
|
||||||
|
/* A flex-item tab strip defaults to min-width:auto, so it grows to its
|
||||||
|
content width inside a narrow modal and the end tabs get clipped by the
|
||||||
|
modal's overflow instead of scrolling. min-width:0 lets the strip shrink
|
||||||
|
to the container so the overflow-x:auto below actually engages. */
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
overflow-x: auto !important;
|
overflow-x: auto !important;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|||||||
+17
-106
@@ -111,9 +111,7 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
row-gap: 4px;
|
row-gap: 4px;
|
||||||
height: 44px;
|
min-height: 40px;
|
||||||
min-height: 44px;
|
|
||||||
max-height: 44px;
|
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -208,19 +206,9 @@
|
|||||||
pushed to the right edge. */
|
pushed to the right edge. */
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
/* When transform section is active in controls, suppress any underlying tool section
|
|
||||||
so transform controls (Cancel, Apply, X, Y, W, H) have the full width and are not
|
|
||||||
overlapped or crowded by tool options (e.g. marquee invert/erase buttons). */
|
|
||||||
.ge-controls.ge-transform-active > div:not(#ge-transform-section),
|
|
||||||
.ge-controls:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section),
|
|
||||||
.ge-controls--in-options:has(#ge-transform-section:not([style*="display:none"]):not([style*="display: none"])) > div:not(#ge-transform-section) {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
/* The Text tool's option set is too wide for the thin strip: let the bar grow
|
/* The Text tool's option set is too wide for the thin strip: let the bar grow
|
||||||
and wrap onto extra rows instead of clipping the trailing fields. */
|
and wrap onto extra rows instead of clipping the trailing fields. */
|
||||||
.ge-options-bar.ge-options-tall {
|
.ge-options-bar.ge-options-tall {
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
@@ -266,7 +254,7 @@
|
|||||||
right after the caret instead of leaving a wide gap. */
|
right after the caret instead of leaving a wide gap. */
|
||||||
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
||||||
.ge-controls--in-options .ge-control-row {
|
.ge-controls--in-options .ge-control-row {
|
||||||
margin: 0 !important;
|
margin: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -500,12 +488,8 @@
|
|||||||
}
|
}
|
||||||
.ge-opt-empty { font-size: 11px; opacity: 0.5; }
|
.ge-opt-empty { font-size: 11px; opacity: 0.5; }
|
||||||
.ge-options-empty .ge-options-sep { display: none; }
|
.ge-options-empty .ge-options-sep { display: none; }
|
||||||
/* An editor opened at phone width keeps its tool controls in their own bottom
|
|
||||||
sheet, so the strip has nothing to show. One opened wider than 700px moves
|
|
||||||
the controls into this strip; hiding it then would strand every tool control
|
|
||||||
(tablet widths of 701-820px, or a window narrowed after opening). */
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.ge-options-bar:not(:has(.ge-controls--in-options)) { display: none; }
|
.ge-options-bar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Document tab strip (Photoshop-style) — names the open document and
|
/* Document tab strip (Photoshop-style) — names the open document and
|
||||||
@@ -4214,11 +4198,7 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.ge-save-state-icon { display: inline-block; vertical-align: middle; }
|
.ge-save-state-icon { display: none; }
|
||||||
.ge-save-state-icon[hidden] { display: none !important; }
|
|
||||||
.ge-save-state-saved { color: var(--fg); }
|
|
||||||
.ge-save-state-dirty { color: var(--accent, var(--red)); }
|
|
||||||
.ge-save-state-error { color: var(--danger, #ef4444); }
|
|
||||||
.ge-save-label { white-space: nowrap; min-width: 0; }
|
.ge-save-label { white-space: nowrap; min-width: 0; }
|
||||||
/* Save button: glyph on its own row, then the SAVE action label and the
|
/* Save button: glyph on its own row, then the SAVE action label and the
|
||||||
live draft-status chip share the second row. */
|
live draft-status chip share the second row. */
|
||||||
@@ -6361,10 +6341,6 @@ button .spinner-whirlpool {
|
|||||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.72);
|
||||||
display: none;
|
display: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
contain: strict;
|
|
||||||
will-change: transform;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
}
|
||||||
/* Pixel brush: a hard square footprint instead of a feathered circle. */
|
/* Pixel brush: a hard square footprint instead of a feathered circle. */
|
||||||
.ge-brush-cursor.ge-brush-cursor--pixel {
|
.ge-brush-cursor.ge-brush-cursor--pixel {
|
||||||
@@ -6646,7 +6622,6 @@ button .spinner-whirlpool {
|
|||||||
}
|
}
|
||||||
.ge-right-panel.expanded {
|
.ge-right-panel.expanded {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
z-index: 100;
|
|
||||||
}
|
}
|
||||||
/* Minimized: panel slides almost fully off-screen — only a thin
|
/* Minimized: panel slides almost fully off-screen — only a thin
|
||||||
handle strip remains visible so the user can swipe back up. */
|
handle strip remains visible so the user can swipe back up. */
|
||||||
@@ -8159,12 +8134,7 @@ input.ge-ctl-check:focus-visible {
|
|||||||
}
|
}
|
||||||
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
/* Cover the phone viewport rather than the small, scrollable canvas area;
|
|
||||||
the layers sheet otherwise hides the checklist's lower controls. */
|
|
||||||
.ge-preflight { position: fixed; }
|
|
||||||
.ge-preflight-card { padding: 13px 14px; }
|
.ge-preflight-card { padding: 13px 14px; }
|
||||||
.ge-preflight-foot { flex-wrap: wrap; }
|
|
||||||
.ge-preflight-start { white-space: nowrap; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Quick Edit — premium look ────────────────────────────────────────── */
|
/* ── Quick Edit — premium look ────────────────────────────────────────── */
|
||||||
@@ -8326,37 +8296,17 @@ input.ge-ctl-check:focus-visible {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.ge-editor-footer::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.ge-editor-footer .ge-status-tool,
|
.ge-editor-footer .ge-status-tool,
|
||||||
.ge-editor-footer .ge-status-model,
|
.ge-editor-footer .ge-status-model,
|
||||||
.ge-editor-footer .ge-canvas-size {
|
.ge-editor-footer .ge-canvas-size {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.ge-footer-history {
|
.ge-footer-history { margin-right: 2px; }
|
||||||
margin-right: 2px;
|
.ge-footer-zoom { gap: 3px; min-width: 0; }
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ge-footer-zoom {
|
|
||||||
gap: 3px;
|
|
||||||
min-width: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
.ge-footer-zoom > .ge-btn,
|
.ge-footer-zoom > .ge-btn,
|
||||||
.ge-footer-history > .ge-btn { min-width: 26px; }
|
.ge-footer-history > .ge-btn { min-width: 26px; }
|
||||||
.ge-setup-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
/* Float the footer above the peek/expanded layers sheet so its zoom/setup
|
/* Float the footer above the peek/expanded layers sheet so its zoom/setup
|
||||||
controls stay tappable (position/left/right/bottom set in JS). */
|
controls stay tappable (position/left/right/bottom set in JS). */
|
||||||
.ge-editor-footer.ge-footer-floating {
|
.ge-editor-footer.ge-footer-floating {
|
||||||
@@ -8367,42 +8317,13 @@ input.ge-ctl-check:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile layers header layout */
|
/* Phones: the floating zoom/Setup footer collides with the peek/expanded
|
||||||
|
layers sheet and the Quick Edit card, so remove it entirely on phones.
|
||||||
|
Undo/Redo/History already live in the layers-sheet header, and pinch-zoom
|
||||||
|
replaces the zoom buttons. */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.ge-layers-header {
|
.ge-editor-footer {
|
||||||
overflow-x: auto;
|
display: none !important;
|
||||||
overflow-y: hidden;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
white-space: nowrap;
|
|
||||||
gap: 4px;
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
.ge-layers-header::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.ge-layers-header .ge-footer-zoom {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ge-layers-header .ge-footer-zoom > .ge-btn,
|
|
||||||
.ge-layers-header .ge-footer-history > .ge-btn {
|
|
||||||
min-width: 28px;
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
.ge-layers-header .ge-setup-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-height: 28px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: color-mix(in srgb, var(--panel) 85%, var(--fg) 15%);
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
||||||
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
||||||
@@ -8635,13 +8556,6 @@ body.ge-editor-open .toast.toast-transform-hint {
|
|||||||
position: relative;
|
position: relative;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.ge-toolbar-wrap.ge-flyout-open {
|
|
||||||
z-index: 250;
|
|
||||||
}
|
|
||||||
.ge-toolbar.ge-flyout-open .ge-toolbar-scroll {
|
|
||||||
-webkit-mask-image: none !important;
|
|
||||||
mask-image: none !important;
|
|
||||||
}
|
|
||||||
.ge-toolbar-wrap > .ge-toolbar { flex: 1 1 auto; min-width: 0; }
|
.ge-toolbar-wrap > .ge-toolbar { flex: 1 1 auto; min-width: 0; }
|
||||||
.ge-tool-more {
|
.ge-tool-more {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -8665,7 +8579,6 @@ body.ge-editor-open .toast.toast-transform-hint {
|
|||||||
/* Keep the floating editor footer from stacking on the Quick Edit card. The
|
/* Keep the floating editor footer from stacking on the Quick Edit card. The
|
||||||
card owns the bottom of the canvas while it is open, so the footer (zoom /
|
card owns the bottom of the canvas while it is open, so the footer (zoom /
|
||||||
Setup / AI settings) hides until it closes. */
|
Setup / AI settings) hides until it closes. */
|
||||||
body.ge-qe-open .ge-editor-footer.ge-footer-floating,
|
|
||||||
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
|
body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -8744,14 +8657,16 @@ body.ge-editor-open .toast.toast-transform-hint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phone menu bar: tighten the Photoshop menus. The compact topbar already
|
/* Phone menu bar: tighten the Photoshop menus and keep the Save control
|
||||||
drops the Save action label; the draft status stays visible because it is
|
icon-only so the twelve menus + Import + Save are not crowded. */
|
||||||
the save button's only announced (role="status") save state. */
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.ge-topbar-left .ge-stacked-btn {
|
.ge-topbar-left .ge-stacked-btn {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 0 6px !important;
|
padding: 0 6px !important;
|
||||||
}
|
}
|
||||||
|
.ge-topbar.ge-topbar-compact #ge-draft-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
|
/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The
|
||||||
@@ -9760,15 +9675,12 @@ body.ge-editor-open .tour-hint {
|
|||||||
}
|
}
|
||||||
/* The dropdowns clip overflow to draw the custom scroll thumb; release the
|
/* The dropdowns clip overflow to draw the custom scroll thumb; release the
|
||||||
clip while a flyout is open/hovered so it can extend past the menu edge. */
|
clip while a flyout is open/hovered so it can extend past the menu edge. */
|
||||||
.ge-filter-menu.ge-flyout-open,
|
|
||||||
.ge-filter-menu:has(.ge-menu-parent.open),
|
.ge-filter-menu:has(.ge-menu-parent.open),
|
||||||
.ge-filter-menu:has(.ge-menu-parent:hover),
|
.ge-filter-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-filter-menu:has(.ge-submenu:hover),
|
.ge-filter-menu:has(.ge-submenu:hover),
|
||||||
.ge-image-menu.ge-flyout-open,
|
|
||||||
.ge-image-menu:has(.ge-menu-parent.open),
|
.ge-image-menu:has(.ge-menu-parent.open),
|
||||||
.ge-image-menu:has(.ge-menu-parent:hover),
|
.ge-image-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-image-menu:has(.ge-submenu:hover),
|
.ge-image-menu:has(.ge-submenu:hover),
|
||||||
.ge-selection-menu.ge-flyout-open,
|
|
||||||
.ge-selection-menu:has(.ge-menu-parent.open),
|
.ge-selection-menu:has(.ge-menu-parent.open),
|
||||||
.ge-selection-menu:has(.ge-menu-parent:hover),
|
.ge-selection-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-selection-menu:has(.ge-submenu:hover) {
|
.ge-selection-menu:has(.ge-submenu:hover) {
|
||||||
@@ -9777,7 +9689,6 @@ body.ge-editor-open .tour-hint {
|
|||||||
mask-image: none;
|
mask-image: none;
|
||||||
}
|
}
|
||||||
/* The Window menu's Workspace flyout needs the same clip release. */
|
/* The Window menu's Workspace flyout needs the same clip release. */
|
||||||
.ge-window-menu.ge-flyout-open,
|
|
||||||
.ge-window-menu:has(.ge-menu-parent.open),
|
.ge-window-menu:has(.ge-menu-parent.open),
|
||||||
.ge-window-menu:has(.ge-menu-parent:hover),
|
.ge-window-menu:has(.ge-menu-parent:hover),
|
||||||
.ge-window-menu:has(.ge-submenu:hover) {
|
.ge-window-menu:has(.ge-submenu:hover) {
|
||||||
|
|||||||
+1
-1
@@ -257,7 +257,7 @@
|
|||||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261017titlefade1">
|
||||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||||
|
|||||||
+26
-243
@@ -44,187 +44,34 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
||||||
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||||
|
|
||||||
// Inline chat images share the editor's 8192px dimension ceiling, with a
|
// URL of the photo attached to the current turn (chat uploads are served at
|
||||||
// conservative 16MP budget for its multiple canvas surfaces. No blob URLs are
|
// /api/upload/<id>). Used by the "Open in editor & apply" action.
|
||||||
// needed here: chat previews use server-authorized upload references.
|
function _currentAttachedImageUrl() {
|
||||||
const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
|
|
||||||
const _MAX_INLINE_BYTES = 7 * 1024 * 1024;
|
|
||||||
const _MAX_IMAGE_DIMENSION = 8192;
|
|
||||||
const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
|
|
||||||
let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode.
|
|
||||||
|
|
||||||
function _imageDimensionsAllowed(width, height) {
|
|
||||||
return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION &&
|
|
||||||
height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _rasterDimensions(bytes, mime) {
|
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
||||||
const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0));
|
|
||||||
if (mime === 'image/png' && bytes.length >= 33 &&
|
|
||||||
bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) {
|
|
||||||
return [view.getUint32(16), view.getUint32(20)];
|
|
||||||
}
|
|
||||||
if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) {
|
|
||||||
return [view.getUint16(6, true), view.getUint16(8, true)];
|
|
||||||
}
|
|
||||||
if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) {
|
|
||||||
let pos = 2;
|
|
||||||
while (pos + 4 <= bytes.length) {
|
|
||||||
if (bytes[pos++] !== 255) return null;
|
|
||||||
while (bytes[pos] === 255) pos++;
|
|
||||||
const marker = bytes[pos++];
|
|
||||||
if (marker === 218 || marker === 217) break;
|
|
||||||
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
|
|
||||||
if (pos + 2 > bytes.length) return null;
|
|
||||||
const size = view.getUint16(pos);
|
|
||||||
if (size < 2 || pos + size > bytes.length) return null;
|
|
||||||
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) {
|
|
||||||
return [view.getUint16(pos + 5), view.getUint16(pos + 3)];
|
|
||||||
}
|
|
||||||
pos += size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') &&
|
|
||||||
view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) {
|
|
||||||
if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)];
|
|
||||||
if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)];
|
|
||||||
if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _inlineRaster(url) {
|
|
||||||
if (_inlineRasterCache?.url === url) return _inlineRasterCache;
|
|
||||||
const result = { url, valid: false };
|
|
||||||
_inlineRasterCache = result;
|
|
||||||
if (url.length > _MAX_DATA_URL_LENGTH) return result;
|
|
||||||
const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url);
|
|
||||||
if (!match) return result;
|
|
||||||
const payload = url.slice(match[0].length);
|
|
||||||
if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result;
|
|
||||||
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
|
|
||||||
const size = payload.length / 4 * 3 - padding;
|
|
||||||
if (size > _MAX_INLINE_BYTES) return result;
|
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
||||||
if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result;
|
|
||||||
try {
|
try {
|
||||||
const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => {
|
const box = document.getElementById('chat-history');
|
||||||
const binary = atob(payload); const array = new Uint8Array(binary.length);
|
if (!box) return null;
|
||||||
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
|
// Use the newest user bubble that carries an image preview. Note
|
||||||
return array;
|
// ':last-of-type' is wrong here (it selects the last sibling of the
|
||||||
})();
|
// element type, and the assistant reply follows the user bubble), so
|
||||||
const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg');
|
// walk the user bubbles from the end instead.
|
||||||
const dimensions = _rasterDimensions(bytes, mime);
|
const users = box.querySelectorAll('.msg-user');
|
||||||
if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result;
|
for (let i = users.length - 1; i >= 0; i--) {
|
||||||
Object.assign(result, { valid: true, bytes, mime, dimensions });
|
const img = users[i].querySelector('.attach-image-preview[data-file-id] img');
|
||||||
} catch (_) {}
|
if (img && img.getAttribute('src')) return img.getAttribute('src');
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This synchronous guard rejects untrusted references before any fetch/decode.
|
|
||||||
// Inline images additionally require a successful browser decode before launch.
|
|
||||||
function _isAuthorizedImageUrl(url) {
|
|
||||||
if (!url || typeof url !== 'string') return false;
|
|
||||||
const trimmed = url.trim();
|
|
||||||
if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid;
|
|
||||||
if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false;
|
|
||||||
try {
|
|
||||||
const base = window.location.origin;
|
|
||||||
const parsed = new URL(trimmed, base);
|
|
||||||
return parsed.origin === base && !parsed.search && !parsed.hash &&
|
|
||||||
/^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname);
|
|
||||||
} catch (_) { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _validateInlineImage(url) {
|
|
||||||
const raster = _inlineRaster(url.trim());
|
|
||||||
if (!raster.valid) return false;
|
|
||||||
if (!raster.decoded) raster.decoded = (async () => {
|
|
||||||
try {
|
|
||||||
const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime }));
|
|
||||||
try {
|
|
||||||
return _imageDimensionsAllowed(image.width, image.height) &&
|
|
||||||
image.width * image.height === raster.dimensions[0] * raster.dimensions[1];
|
|
||||||
} finally { image.close(); }
|
|
||||||
} catch (_) { return false; }
|
|
||||||
finally { raster.bytes = null; }
|
|
||||||
})();
|
|
||||||
return raster.decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the specific user message that originated the turn containing container.
|
|
||||||
function _findOriginatingUserMessage(container) {
|
|
||||||
if (!container) return null;
|
|
||||||
let curr = container.previousElementSibling;
|
|
||||||
while (curr) {
|
|
||||||
if (curr.classList && curr.classList.contains('msg-user')) {
|
|
||||||
return curr;
|
|
||||||
}
|
}
|
||||||
curr = curr.previousElementSibling;
|
// Fall back to any attachment preview/card in the history (newest).
|
||||||
}
|
const previews = box.querySelectorAll(
|
||||||
const box = document.getElementById('chat-history');
|
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
|
||||||
if (box && box.contains(container)) {
|
);
|
||||||
const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder'));
|
if (previews.length) {
|
||||||
const idx = all.findIndex((el) => el === container || el.contains(container));
|
const last = previews[previews.length - 1];
|
||||||
if (idx > 0) {
|
const id = last.dataset ? last.dataset.fileId : null;
|
||||||
for (let i = idx - 1; i >= 0; i--) {
|
if (id) return API_BASE + '/api/upload/' + id;
|
||||||
if (all[i].classList && all[i].classList.contains('msg-user')) {
|
|
||||||
return all[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the image attached to the exact turn associated with container / action.
|
|
||||||
// Never falls back to unrelated images from earlier conversation turns.
|
|
||||||
function _attachedImageForTurn(container, action) {
|
|
||||||
const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id);
|
|
||||||
if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) {
|
|
||||||
return (API_BASE || '') + '/api/upload/' + explicitId;
|
|
||||||
}
|
|
||||||
const explicitUrl = action && (action.imageUrl || action.image_url);
|
|
||||||
if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null;
|
|
||||||
|
|
||||||
const userMsg = _findOriginatingUserMessage(container);
|
|
||||||
if (!userMsg) return null;
|
|
||||||
|
|
||||||
const previews = userMsg.querySelectorAll(
|
|
||||||
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
|
|
||||||
);
|
|
||||||
for (let i = previews.length - 1; i >= 0; i--) {
|
|
||||||
const fid = previews[i].dataset ? previews[i].dataset.fileId : null;
|
|
||||||
if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) {
|
|
||||||
return (API_BASE || '') + '/api/upload/' + fid;
|
|
||||||
}
|
|
||||||
const img = previews[i].querySelector('img');
|
|
||||||
const src = img ? (img.getAttribute('src') || '') : '';
|
|
||||||
if (_isAuthorizedImageUrl(src)) return src;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img');
|
|
||||||
for (let i = imgs.length - 1; i >= 0; i--) {
|
|
||||||
const src = imgs[i].getAttribute('src') || '';
|
|
||||||
if (_isAuthorizedImageUrl(src)) return src;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _currentAttachedImageUrl(container, action) {
|
|
||||||
return _attachedImageForTurn(container, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _notifyError(msg) {
|
|
||||||
try {
|
|
||||||
if (uiModule && typeof uiModule.showError === 'function') {
|
|
||||||
uiModule.showError(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
console.warn('[chat]', msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the one-click "Open in editor & apply" button on a photo-edit reply
|
// Render the one-click "Open in editor & apply" button on a photo-edit reply
|
||||||
@@ -238,89 +85,25 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
btn.textContent = 'Open in editor & apply';
|
btn.textContent = 'Open in editor & apply';
|
||||||
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
|
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (btn.disabled) return;
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
let imageUrl = _attachedImageForTurn(container, action);
|
|
||||||
if (!imageUrl) {
|
|
||||||
_notifyError('No attached photo found for this conversation turn.');
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_isAuthorizedImageUrl(imageUrl)) {
|
|
||||||
_notifyError('Unauthorized or invalid image reference.');
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) {
|
|
||||||
_notifyError('Invalid or oversized image attachment.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Gallery references name an owner-scoped metadata resource, not image bytes.
|
|
||||||
if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) {
|
|
||||||
const response = await fetch(imageUrl, { credentials: 'same-origin' });
|
|
||||||
if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; }
|
|
||||||
const image = await response.json();
|
|
||||||
const source = new URL(image.url || '', window.location.origin);
|
|
||||||
if (source.origin !== window.location.origin || source.search || source.hash ||
|
|
||||||
!/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) {
|
|
||||||
_notifyError('Invalid gallery image reference.'); return;
|
|
||||||
}
|
|
||||||
imageUrl = source.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify attachment is accessible and authorized on the server before launching editor
|
|
||||||
if (imageUrl.includes('/api/upload/')) {
|
|
||||||
try {
|
|
||||||
const probe = await fetch(imageUrl, { method: 'HEAD' });
|
|
||||||
if (!probe.ok) {
|
|
||||||
if (probe.status === 404 || probe.status === 410) {
|
|
||||||
_notifyError('The attached image is no longer available or upload has expired.');
|
|
||||||
} else if (probe.status === 401 || probe.status === 403) {
|
|
||||||
_notifyError('You are not authorized to edit this attached image.');
|
|
||||||
} else {
|
|
||||||
_notifyError('Could not verify image attachment authorization.');
|
|
||||||
}
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
_notifyError('Could not verify image attachment authorization.');
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window.gePhotoEditorApply !== 'function') {
|
if (typeof window.gePhotoEditorApply !== 'function') {
|
||||||
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
|
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
|
||||||
}
|
}
|
||||||
await window.gePhotoEditorApply({
|
await window.gePhotoEditorApply({
|
||||||
imageId: (action && (action.imageId || action.image_id)) || null,
|
imageId: null,
|
||||||
imageUrl,
|
imageUrl: _currentAttachedImageUrl(),
|
||||||
prompt: action.prompt || '',
|
prompt: action.prompt || '',
|
||||||
displayName: 'Chat edit',
|
displayName: 'Chat edit',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[chat] open_and_apply failed', err);
|
console.error('[chat] open_and_apply failed', err);
|
||||||
_notifyError('Failed to apply edit: ' + (err.message || err));
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.__chatPhotoEditorTestHooks = {
|
|
||||||
_isAuthorizedImageUrl,
|
|
||||||
_findOriginatingUserMessage,
|
|
||||||
_attachedImageForTurn,
|
|
||||||
renderPhotoEditorApplyButton,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let currentAbort = null;
|
let currentAbort = null;
|
||||||
let isStreaming = false;
|
let isStreaming = false;
|
||||||
|
|||||||
@@ -3277,6 +3277,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
let lastMsgAi = null;
|
let lastMsgAi = null;
|
||||||
|
const newNodes = [];
|
||||||
|
|
||||||
const toolsByRound = {};
|
const toolsByRound = {};
|
||||||
let lastPrivateBrowserUrl = '';
|
let lastPrivateBrowserUrl = '';
|
||||||
@@ -3370,6 +3371,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
wrap.dataset.raw = txt;
|
wrap.dataset.raw = txt;
|
||||||
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
|
||||||
box.appendChild(wrap);
|
box.appendChild(wrap);
|
||||||
|
newNodes.push(wrap);
|
||||||
lastWrap = wrap;
|
lastWrap = wrap;
|
||||||
if (!firstMsgAi) firstMsgAi = wrap;
|
if (!firstMsgAi) firstMsgAi = wrap;
|
||||||
lastMsgAi = wrap;
|
lastMsgAi = wrap;
|
||||||
@@ -3387,6 +3389,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
// Extend line up if there's a chat bubble above
|
// Extend line up if there's a chat bubble above
|
||||||
if (txt) threadWrap.classList.add('has-top');
|
if (txt) threadWrap.classList.add('has-top');
|
||||||
box.appendChild(threadWrap);
|
box.appendChild(threadWrap);
|
||||||
|
newNodes.push(threadWrap);
|
||||||
}
|
}
|
||||||
for (const ev of roundTools) {
|
for (const ev of roundTools) {
|
||||||
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
|
||||||
@@ -3448,6 +3451,7 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
if (image) {
|
if (image) {
|
||||||
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
|
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
|
||||||
box.appendChild(imgBubble);
|
box.appendChild(imgBubble);
|
||||||
|
newNodes.push(imgBubble);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-15
@@ -72,12 +72,7 @@ function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g
|
|||||||
|
|
||||||
// ── Storage ───────────────────────────────────────────────────────────
|
// ── Storage ───────────────────────────────────────────────────────────
|
||||||
function getRecents() {
|
function getRecents() {
|
||||||
try {
|
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
|
||||||
const values = JSON.parse(localStorage.getItem(LS_RECENT) || '[]');
|
|
||||||
return Array.isArray(values)
|
|
||||||
? values.filter(value => typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)).slice(0, MAX_RECENT)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,26 +85,30 @@ function addRecent(hex) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Suggestions based on current color (5 harmony swatches) ──────────
|
// ── Suggestions based on current color (5 harmony swatches) ──────────
|
||||||
function suggestionsForHsv(h, s, v) {
|
function computeSuggestions() {
|
||||||
// Complement, analogous ±30°, split-complement (+150), tone shift
|
// Complement, analogous ±30°, split-complement (+150), tone shift
|
||||||
return [
|
return [
|
||||||
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
|
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
|
||||||
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
|
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
|
||||||
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
|
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
|
||||||
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
|
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
|
||||||
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reusable helpers for hosts that surface swatches inline ──────────
|
// ── Reusable helpers for hosts that surface swatches inline ──────────
|
||||||
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
|
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
|
||||||
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
|
|
||||||
|
|
||||||
export function getRecentColors() { return getRecents(); }
|
export function getRecentColors() { return getRecents(); }
|
||||||
export function recordRecentColor(hex) { addRecent(hex); }
|
export function recordRecentColor(hex) { addRecent(hex); }
|
||||||
export function suggestColorsFor(hex) {
|
export function suggestColorsFor(hex) {
|
||||||
const { h, s, v } = hexToHsv(hex || '#000000');
|
const { h, s, v } = hexToHsv(hex || '#000000');
|
||||||
return suggestionsForHsv(h, s, v);
|
return [
|
||||||
|
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
|
||||||
|
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
|
||||||
|
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
|
||||||
|
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
|
||||||
|
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Popover build ─────────────────────────────────────────────────────
|
// ── Popover build ─────────────────────────────────────────────────────
|
||||||
|
|||||||
+5
-42
@@ -1996,17 +1996,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
|
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
|
||||||
// next click on a page consumes it. null means clicks do nothing.
|
// next click on a page consumes it. null means clicks do nothing.
|
||||||
let _pdfDropMode = null;
|
let _pdfDropMode = null;
|
||||||
let _pdfAnnotationDragging = false;
|
|
||||||
function _setPdfAnnotationDragging(dragging) {
|
|
||||||
_pdfAnnotationDragging = dragging;
|
|
||||||
for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) {
|
|
||||||
ref._refreshHandles?.();
|
|
||||||
if (dragging) {
|
|
||||||
const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu');
|
|
||||||
if (menu) menu.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Per-doc last-used line spacing for text annotations. Once the user picks
|
// Per-doc last-used line spacing for text annotations. Once the user picks
|
||||||
// 1.6 for one box, every text box dropped after that defaults to 1.6.
|
// 1.6 for one box, every text box dropped after that defaults to 1.6.
|
||||||
const _pdfLastLineHeight = new Map(); // docId -> number
|
const _pdfLastLineHeight = new Map(); // docId -> number
|
||||||
@@ -2301,16 +2290,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
// active (toolbar buttons set the mode). Without a mode, clicking does
|
// active (toolbar buttons set the mode). Without a mode, clicking does
|
||||||
// nothing — keeps page interactions predictable so users don't get
|
// nothing — keeps page interactions predictable so users don't get
|
||||||
// surprise boxes from stray clicks.
|
// surprise boxes from stray clicks.
|
||||||
const isBlankPageTarget = target => target === pageWrap
|
|
||||||
|| (target?.tagName === 'IMG' && target.parentElement === pageWrap);
|
|
||||||
let placementStartedOnPage = false;
|
|
||||||
pageWrap.addEventListener('pointerdown', ev => {
|
|
||||||
placementStartedOnPage = isBlankPageTarget(ev.target);
|
|
||||||
}, true);
|
|
||||||
pageWrap.addEventListener('click', (ev) => {
|
pageWrap.addEventListener('click', (ev) => {
|
||||||
const canPlace = placementStartedOnPage;
|
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
|
||||||
placementStartedOnPage = false;
|
|
||||||
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
|
|
||||||
if (!_pdfDropMode) return;
|
if (!_pdfDropMode) return;
|
||||||
const rect = pageWrap.getBoundingClientRect();
|
const rect = pageWrap.getBoundingClientRect();
|
||||||
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
|
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||||
@@ -2442,11 +2423,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
|
|
||||||
// Set handle visibility together; clicking/tapping the annotation itself
|
// Set handle visibility together; clicking/tapping the annotation itself
|
||||||
// brings hidden controls back.
|
// brings hidden controls back.
|
||||||
let handlesRequested = _isTouch;
|
|
||||||
const _setHandlesVisible = (show) => {
|
const _setHandlesVisible = (show) => {
|
||||||
if (!_pdfAnnotationDragging) handlesRequested = show;
|
|
||||||
const dismissed = wrap.dataset.controlsDismissed === '1';
|
const dismissed = wrap.dataset.controlsDismissed === '1';
|
||||||
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
|
const v = (show && !dismissed) ? '' : 'none';
|
||||||
del.style.display = v;
|
del.style.display = v;
|
||||||
grip.style.display = v;
|
grip.style.display = v;
|
||||||
resize.style.display = v;
|
resize.style.display = v;
|
||||||
@@ -2467,8 +2446,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
_setHandlesVisible(true);
|
_setHandlesVisible(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
|
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible };
|
||||||
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
|
|
||||||
|
|
||||||
if (kind === 'check') {
|
if (kind === 'check') {
|
||||||
// Stamp checkmark — value is fixed, nothing to listen for.
|
// Stamp checkmark — value is fixed, nothing to listen for.
|
||||||
@@ -2590,12 +2568,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
ref._autoGrow = _autoGrow;
|
ref._autoGrow = _autoGrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
del.addEventListener('pointerdown', ev => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
});
|
|
||||||
del.addEventListener('click', (ev) => {
|
del.addEventListener('click', (ev) => {
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_pushPdfUndoSnapshot();
|
_pushPdfUndoSnapshot();
|
||||||
_removeAnnotation(ref);
|
_removeAnnotation(ref);
|
||||||
@@ -2607,7 +2580,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_pushPdfUndoSnapshot();
|
_pushPdfUndoSnapshot();
|
||||||
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
|
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||||
_setPdfAnnotationDragging(true);
|
|
||||||
// Hide the × and resize handles while moving so they don't obscure the
|
// Hide the × and resize handles while moving so they don't obscure the
|
||||||
// box — easier to see exactly where it lands. Restored on release.
|
// box — easier to see exactly where it lands. Restored on release.
|
||||||
del.style.display = 'none';
|
del.style.display = 'none';
|
||||||
@@ -2626,14 +2598,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('pointermove', onMove);
|
document.removeEventListener('pointermove', onMove);
|
||||||
document.removeEventListener('pointerup', onUp);
|
document.removeEventListener('pointerup', onUp);
|
||||||
document.removeEventListener('pointercancel', onUp);
|
|
||||||
_setPdfAnnotationDragging(false);
|
|
||||||
_setHandlesVisible(true);
|
_setHandlesVisible(true);
|
||||||
_schedulePdfPaneSave();
|
_schedulePdfPaneSave();
|
||||||
};
|
};
|
||||||
document.addEventListener('pointermove', onMove);
|
document.addEventListener('pointermove', onMove);
|
||||||
document.addEventListener('pointerup', onUp);
|
document.addEventListener('pointerup', onUp);
|
||||||
document.addEventListener('pointercancel', onUp);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag bottom-right corner to resize. Width/height stored as percentages.
|
// Drag bottom-right corner to resize. Width/height stored as percentages.
|
||||||
@@ -2642,7 +2611,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
_pushPdfUndoSnapshot();
|
_pushPdfUndoSnapshot();
|
||||||
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
|
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||||
_setPdfAnnotationDragging(true);
|
|
||||||
// Hide the × and move handles while resizing — clean view of the box edge.
|
// Hide the × and move handles while resizing — clean view of the box edge.
|
||||||
del.style.display = 'none';
|
del.style.display = 'none';
|
||||||
grip.style.display = 'none';
|
grip.style.display = 'none';
|
||||||
@@ -2660,14 +2628,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('pointermove', onMove);
|
document.removeEventListener('pointermove', onMove);
|
||||||
document.removeEventListener('pointerup', onUp);
|
document.removeEventListener('pointerup', onUp);
|
||||||
document.removeEventListener('pointercancel', onUp);
|
|
||||||
_setPdfAnnotationDragging(false);
|
|
||||||
_setHandlesVisible(true);
|
_setHandlesVisible(true);
|
||||||
_schedulePdfPaneSave();
|
_schedulePdfPaneSave();
|
||||||
};
|
};
|
||||||
document.addEventListener('pointermove', onMove);
|
document.addEventListener('pointermove', onMove);
|
||||||
document.addEventListener('pointerup', onUp);
|
document.addEventListener('pointerup', onUp);
|
||||||
document.addEventListener('pointercancel', onUp);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Text options menu — opened from the floating … button so the spacing
|
// Text options menu — opened from the floating … button so the spacing
|
||||||
@@ -15274,8 +15239,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
menu.style.top = (rect.bottom + 2) + 'px';
|
menu.style.top = (rect.bottom + 2) + 'px';
|
||||||
menu.style.right = (window.innerWidth - rect.right) + 'px';
|
menu.style.right = (window.innerWidth - rect.right) + 'px';
|
||||||
menu.style.left = 'auto';
|
menu.style.left = 'auto';
|
||||||
const editorPane = document.querySelector('.doc-editor-pane');
|
menu.style.zIndex = '9999';
|
||||||
menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1));
|
|
||||||
|
|
||||||
const langLabel = lang ? lang.toUpperCase() : 'TXT';
|
const langLabel = lang ? lang.toUpperCase() : 'TXT';
|
||||||
// Form-backed markdown doc → primary export is the filled PDF, not the
|
// Form-backed markdown doc → primary export is the filled PDF, not the
|
||||||
@@ -15350,8 +15314,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
|||||||
}
|
}
|
||||||
// Outside-click AND Escape both route through the central esc-stack via
|
// Outside-click AND Escape both route through the central esc-stack via
|
||||||
// bindMenuDismiss; onClose owns the actual node removal.
|
// bindMenuDismiss; onClose owns the actual node removal.
|
||||||
const exportBtn = document.getElementById('doc-footer-export-btn');
|
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||||
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _richTextExportCss() {
|
function _richTextExportCss() {
|
||||||
|
|||||||
+111
-154
@@ -82,9 +82,10 @@ export function resolveQuickEditAgentModel() {
|
|||||||
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
|
return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick Edit agent (planner) model options. Kept small + explicit;
|
// Quick Edit agent (planner) model options. Kept small + explicit; the local
|
||||||
// dynamically populated from /api/editor/agent-models.
|
// Qwen3.5-9B VL is the default. `value` encodes "<endpoint>::<model>".
|
||||||
export const QUICK_EDIT_AGENT_OPTIONS = [
|
export const QUICK_EDIT_AGENT_OPTIONS = [
|
||||||
|
{ value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' },
|
||||||
{ value: 'default', label: 'Server default (hosted planner)' },
|
{ value: 'default', label: 'Server default (hosted planner)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -140,41 +141,12 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
|||||||
return { gen: false, inpaint: false };
|
return { gen: false, inpaint: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global listeners resolve live controls instead of retaining editor instances.
|
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
|
||||||
function syncCommandModel(value) {
|
|
||||||
const select = document.getElementById('ge-ai-command-model');
|
|
||||||
if (!select || !select._modelsReady) return;
|
|
||||||
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
|
|
||||||
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
|
|
||||||
value = '';
|
|
||||||
}
|
|
||||||
if (select.value !== value) {
|
|
||||||
select.value = value;
|
|
||||||
if (select.value !== value) select.value = '';
|
|
||||||
updateFooterEditModel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
|
|
||||||
window.addEventListener('storage', e => {
|
|
||||||
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
|
|
||||||
});
|
|
||||||
window.addEventListener('ge:model-endpoints-updated', e => {
|
|
||||||
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) {
|
|
||||||
const root = container || document;
|
|
||||||
root._aiModelListeners?.abort();
|
|
||||||
const listeners = new AbortController();
|
|
||||||
root._aiModelListeners = listeners;
|
|
||||||
const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, {
|
|
||||||
...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal,
|
|
||||||
});
|
|
||||||
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
|
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
|
||||||
// option — catches clicks regardless of whether loadAIModels has
|
// option — catches clicks regardless of whether loadAIModels has
|
||||||
// rewired the individual select yet and survives any innerHTML
|
// rewired the individual select yet and survives any innerHTML
|
||||||
// reset later.
|
// reset later.
|
||||||
listen(root, 'change', (e) => {
|
container.addEventListener('change', (e) => {
|
||||||
const sel = e.target.closest('select');
|
const sel = e.target.closest('select');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
if (sel.value !== '__serve_cookbook__') return;
|
if (sel.value !== '__serve_cookbook__') return;
|
||||||
@@ -184,7 +156,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
openCookbookForImg2img();
|
openCookbookForImg2img();
|
||||||
});
|
});
|
||||||
// Track prior value so we can restore it after the sentinel fires.
|
// Track prior value so we can restore it after the sentinel fires.
|
||||||
listen(root, 'focus', (e) => {
|
container.addEventListener('focus', (e) => {
|
||||||
const sel = e.target.closest('select');
|
const sel = e.target.closest('select');
|
||||||
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
|
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
|
||||||
}, true);
|
}, true);
|
||||||
@@ -193,31 +165,14 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
|
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
|
||||||
const aiCommandSelect = document.getElementById('ge-ai-command-model');
|
const aiCommandSelect = document.getElementById('ge-ai-command-model');
|
||||||
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
|
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
|
||||||
if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel());
|
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
|
||||||
// The Quick Edit agent (planner) picker: dynamically populates from
|
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
|
||||||
// /api/editor/agent-models; restore/save choice from localStorage.
|
// The Quick Edit agent (planner) picker is static for now (local VL default,
|
||||||
const populateAgentSelect = async () => {
|
// server default option); restore/save its choice from localStorage.
|
||||||
|
const populateAgentSelect = () => {
|
||||||
if (!aiCommandAgentSelect) return;
|
if (!aiCommandAgentSelect) return;
|
||||||
const prevVal = aiCommandAgentSelect.value;
|
|
||||||
let agentOptions = [...QUICK_EDIT_AGENT_OPTIONS];
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${apiBase}/api/editor/agent-models`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (listeners.signal.aborted || !root.isConnected) return;
|
|
||||||
const serverModels = data.models || [];
|
|
||||||
for (const m of serverModels) {
|
|
||||||
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`;
|
|
||||||
const label = m.label || m.id;
|
|
||||||
if (!agentOptions.some(o => o.value === optVal)) {
|
|
||||||
agentOptions.push({ value: optVal, label });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
aiCommandAgentSelect.innerHTML = '';
|
aiCommandAgentSelect.innerHTML = '';
|
||||||
for (const opt of agentOptions) {
|
for (const opt of QUICK_EDIT_AGENT_OPTIONS) {
|
||||||
const el = document.createElement('option');
|
const el = document.createElement('option');
|
||||||
el.value = opt.value;
|
el.value = opt.value;
|
||||||
el.textContent = opt.label;
|
el.textContent = opt.label;
|
||||||
@@ -227,13 +182,11 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
const saved = localStorage.getItem('ge-ai-command-agent-model');
|
const saved = localStorage.getItem('ge-ai-command-agent-model');
|
||||||
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
|
if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) {
|
||||||
aiCommandAgentSelect.value = saved;
|
aiCommandAgentSelect.value = saved;
|
||||||
} else if (prevVal && [...aiCommandAgentSelect.options].some(o => o.value === prevVal)) {
|
|
||||||
aiCommandAgentSelect.value = prevVal;
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
if (aiCommandAgentSelect._agentWired !== listeners) {
|
if (!aiCommandAgentSelect._agentWired) {
|
||||||
aiCommandAgentSelect._agentWired = listeners;
|
aiCommandAgentSelect._agentWired = true;
|
||||||
listen(aiCommandAgentSelect, 'change', () => {
|
aiCommandAgentSelect.addEventListener('change', () => {
|
||||||
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
|
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -243,19 +196,16 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
// select nor the inpaint select nor any per-tool select).
|
// select nor the inpaint select nor any per-tool select).
|
||||||
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
|
if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect &&
|
||||||
!document.querySelector('select.ge-tool-model')) return;
|
!document.querySelector('select.ge-tool-model')) return;
|
||||||
void populateAgentSelect();
|
populateAgentSelect();
|
||||||
|
|
||||||
async function loadAIModels(opts = {}) {
|
async function loadAIModels(opts = {}) {
|
||||||
try {
|
try {
|
||||||
const selectBaseUrl = opts.selectBaseUrl || '';
|
const selectBaseUrl = opts.selectBaseUrl || '';
|
||||||
const prevGenValue = aiGenSelect?.value || '';
|
const prevGenValue = aiGenSelect?.value || '';
|
||||||
const prevInpaintValue = aiInpaintSelect?.value || '';
|
const prevInpaintValue = aiInpaintSelect?.value || '';
|
||||||
const res = await fetch(`${apiBase}/api/editor/image-models`);
|
const res = await fetch(`${apiBase}/api/model-endpoints`);
|
||||||
if (listeners.signal.aborted || !root.isConnected) return;
|
|
||||||
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
||||||
const data = await res.json();
|
const endpoints = await res.json();
|
||||||
if (listeners.signal.aborted || !root.isConnected) return;
|
|
||||||
const modelsList = data.models || [];
|
|
||||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
||||||
@@ -265,52 +215,59 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
let selectedGen = null;
|
let selectedGen = null;
|
||||||
let selectedInpaint = null;
|
let selectedInpaint = null;
|
||||||
const loaded = [];
|
const loaded = [];
|
||||||
for (const m of modelsList) {
|
for (const ep of endpoints) {
|
||||||
const modelId = m.id || '';
|
if (!ep.is_enabled) continue;
|
||||||
const caps = modelCaps(modelId, m.provider, m.kind);
|
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
|
||||||
const canGen = Boolean(caps.gen || m.kind === 'generate');
|
const models = hasListedModels ? sortModelIds(ep.models) : [''];
|
||||||
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
|
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
|
||||||
if (!canGen && !canInpaint) continue;
|
// Image/inpaint endpoints can be called by URL even when their
|
||||||
|
// /models cache is still empty, so don't strand a freshly served
|
||||||
// Use opaque endpoint_id if available; never fall back to raw base_url
|
// Cookbook model as "(offline)" in the editor picker.
|
||||||
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
|
const epUsable = !!ep.online || isImageEndpoint;
|
||||||
const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
|
for (const modelId of models) {
|
||||||
const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
|
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
|
||||||
const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
|
if (!caps.gen && !caps.inpaint) continue;
|
||||||
const label = `${shortModel}${epHint}`;
|
// Encode "<base_url>::<model_id>" so the value carries both pieces.
|
||||||
const epUsable = true;
|
const value = `${ep.base_url}::${modelId}`;
|
||||||
loaded.push({ value, label, gen: canGen, inpaint: canInpaint, usable: epUsable, endpoint: endpointKey, model: modelId });
|
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
|
||||||
if (canGen && aiGenSelect) {
|
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
|
||||||
const opt = document.createElement('option');
|
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
|
||||||
opt.value = value;
|
loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId });
|
||||||
opt.textContent = label;
|
if (caps.gen && aiGenSelect) {
|
||||||
opt.disabled = !epUsable;
|
|
||||||
aiGenSelect.appendChild(opt);
|
|
||||||
if (epUsable && !firstGen) firstGen = value;
|
|
||||||
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedGen) selectedGen = value;
|
|
||||||
}
|
|
||||||
if (canInpaint && aiInpaintSelect) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = value;
|
|
||||||
opt.textContent = label;
|
|
||||||
opt.disabled = !epUsable;
|
|
||||||
aiInpaintSelect.appendChild(opt);
|
|
||||||
if (epUsable && selectBaseUrl && (endpointKey === selectBaseUrl || m.base_url === selectBaseUrl) && !selectedInpaint) selectedInpaint = value;
|
|
||||||
}
|
|
||||||
if (canInpaint || canGen) {
|
|
||||||
for (const ts of perToolSelects) {
|
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = value;
|
opt.value = value;
|
||||||
opt.textContent = label;
|
opt.textContent = label;
|
||||||
opt.disabled = !epUsable;
|
opt.disabled = !epUsable;
|
||||||
ts.appendChild(opt);
|
aiGenSelect.appendChild(opt);
|
||||||
|
if (epUsable && !firstGen) firstGen = value;
|
||||||
|
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
|
||||||
}
|
}
|
||||||
if (aiCommandSelect) {
|
if (caps.inpaint && aiInpaintSelect) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = value;
|
opt.value = value;
|
||||||
opt.textContent = label;
|
opt.textContent = label;
|
||||||
opt.disabled = !epUsable;
|
opt.disabled = !epUsable;
|
||||||
aiCommandSelect.appendChild(opt);
|
aiInpaintSelect.appendChild(opt);
|
||||||
|
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
|
||||||
|
}
|
||||||
|
// Per-tool selectors get every img2img-capable entry. Both
|
||||||
|
// caps.inpaint AND caps.gen models work for harmonize /
|
||||||
|
// style / upscale (anything that can do img2img).
|
||||||
|
if (caps.inpaint || caps.gen) {
|
||||||
|
for (const ts of perToolSelects) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = value;
|
||||||
|
opt.textContent = label;
|
||||||
|
opt.disabled = !epUsable;
|
||||||
|
ts.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (aiCommandSelect) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = value;
|
||||||
|
opt.textContent = label;
|
||||||
|
opt.disabled = !epUsable;
|
||||||
|
aiCommandSelect.appendChild(opt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,50 +300,32 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
||||||
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
||||||
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
|
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
|
||||||
// Sentinel changes are handled by the component's delegated listener.
|
// Wire the sentinel on the Gen + Inpaint selects too.
|
||||||
|
const wireServeSentinel = (sel) => {
|
||||||
|
if (!sel) return;
|
||||||
|
let prev = sel.value;
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
if (sel.value === '__serve_cookbook__') {
|
||||||
|
sel.value = prev;
|
||||||
|
openCookbookForImg2img();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prev = sel.value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
wireServeSentinel(aiGenSelect);
|
||||||
|
wireServeSentinel(aiInpaintSelect);
|
||||||
|
wireServeSentinel(aiCommandSelect);
|
||||||
if (aiCommandSelect) {
|
if (aiCommandSelect) {
|
||||||
aiCommandSelect._modelsReady = true;
|
|
||||||
try {
|
try {
|
||||||
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
|
const saved = localStorage.getItem('ge-ai-command-model');
|
||||||
let saved = rawSaved;
|
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
|
||||||
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
|
||||||
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
|
||||||
const matching = loaded.filter(l => l.model === modelPart);
|
|
||||||
if (matching.length === 1) {
|
|
||||||
saved = matching[0].value;
|
|
||||||
} else {
|
|
||||||
saved = '';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (saved) localStorage.setItem('ge-ai-command-model', saved);
|
|
||||||
else localStorage.removeItem('ge-ai-command-model');
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
|
|
||||||
aiCommandSelect.value = saved;
|
|
||||||
} else {
|
|
||||||
aiCommandSelect.value = '';
|
|
||||||
}
|
|
||||||
if (aiCommandSelect.value !== rawSaved) {
|
|
||||||
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
|
||||||
else localStorage.removeItem('ge-ai-command-model');
|
|
||||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
aiCommandSelect.addEventListener('change', () => {
|
||||||
if (aiCommandSelect._commandWired !== listeners) {
|
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||||
aiCommandSelect._commandWired = listeners;
|
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
|
||||||
listen(aiCommandSelect, 'change', () => {
|
updateFooterEditModel();
|
||||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
});
|
||||||
const val = aiCommandSelect.value || '';
|
|
||||||
try {
|
|
||||||
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
|
||||||
else localStorage.removeItem('ge-ai-command-model');
|
|
||||||
} catch {}
|
|
||||||
updateFooterEditModel();
|
|
||||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Restore each per-tool selection from localStorage.
|
// Restore each per-tool selection from localStorage.
|
||||||
for (const ts of perToolSelects) {
|
for (const ts of perToolSelects) {
|
||||||
@@ -398,7 +337,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
let prevValue = ts.value;
|
let prevValue = ts.value;
|
||||||
listen(ts, 'change', () => {
|
ts.addEventListener('change', () => {
|
||||||
if (ts.value === '__serve_cookbook__') {
|
if (ts.value === '__serve_cookbook__') {
|
||||||
ts.value = prevValue;
|
ts.value = prevValue;
|
||||||
openCookbookForImg2img();
|
openCookbookForImg2img();
|
||||||
@@ -411,7 +350,6 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
||||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (listeners.signal.aborted || !root.isConnected) return;
|
|
||||||
// Fetch failed — still give the user the affordance to set up
|
// Fetch failed — still give the user the affordance to set up
|
||||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||||
// hint about what to do next.
|
// hint about what to do next.
|
||||||
@@ -420,18 +358,37 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||||
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
|
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
|
||||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||||
void populateAgentSelect();
|
const wireServe = (sel) => {
|
||||||
|
if (!sel) return;
|
||||||
|
let prev = sel.value;
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
if (sel.value === '__serve_cookbook__') {
|
||||||
|
sel.value = prev;
|
||||||
|
openCookbookForImg2img();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prev = sel.value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
wireServe(aiGenSelect);
|
||||||
|
wireServe(aiInpaintSelect);
|
||||||
|
wireServe(aiCommandSelect);
|
||||||
|
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
|
||||||
|
populateAgentSelect();
|
||||||
updateFooterEditModel();
|
updateFooterEditModel();
|
||||||
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
|
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
|
||||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadAIModels();
|
loadAIModels();
|
||||||
if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => {
|
const onModelEndpointsUpdated = (e) => {
|
||||||
if (listeners.signal.aborted || !root.isConnected) return;
|
if (!container.isConnected) {
|
||||||
void loadAIModels({ selectBaseUrl: baseUrl });
|
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
||||||
void populateAgentSelect();
|
return;
|
||||||
|
}
|
||||||
|
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
|
||||||
};
|
};
|
||||||
|
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
||||||
// Re-fetch the model list when the user opens the inpaint dropdown,
|
// Re-fetch the model list when the user opens the inpaint dropdown,
|
||||||
// so a model served via Cookbook mid-edit shows up without having to
|
// so a model served via Cookbook mid-edit shows up without having to
|
||||||
// close and reopen the editor. Debounced to one refresh per 3s so
|
// close and reopen the editor. Debounced to one refresh per 3s so
|
||||||
@@ -450,5 +407,5 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
|||||||
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
|
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
listen(root, 'mousedown', refreshOnOpen, true);
|
container.addEventListener('mousedown', refreshOnOpen, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
// entries in a compact `<select>` overlay reachable from a "Panels"
|
// entries in a compact `<select>` overlay reachable from a "Panels"
|
||||||
// button in the layers header, without touching the peek/expand model.
|
// button in the layers header, without touching the peek/expand model.
|
||||||
let mobilePanels = null;
|
let mobilePanels = null;
|
||||||
if (Array.isArray(extraPanels) && extraPanels.length) {
|
if (!useDock && Array.isArray(extraPanels) && extraPanels.length) {
|
||||||
const mDock = buildDock({
|
const mDock = buildDock({
|
||||||
entries: extraPanels,
|
entries: extraPanels,
|
||||||
persistKey: 'ge-dock-active',
|
persistKey: 'ge-dock-active',
|
||||||
@@ -218,9 +218,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
overlay.hidden = !overlay.hidden;
|
overlay.hidden = !overlay.hidden;
|
||||||
});
|
});
|
||||||
hdr.appendChild(panelsBtn);
|
hdr.appendChild(panelsBtn);
|
||||||
if (useDock) panelsBtn.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
if (useDock) overlay.style.display = 'none';
|
|
||||||
mobilePanels = overlay;
|
mobilePanels = overlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +226,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
// The panels are only re-parented, so every existing selector and
|
// The panels are only re-parented, so every existing selector and
|
||||||
// listener keeps working; switching tabs just toggles `hidden`.
|
// listener keeps working; switching tabs just toggles `hidden`.
|
||||||
let dock = null;
|
let dock = null;
|
||||||
if (Array.isArray(extraPanels) && extraPanels.length) {
|
if (useDock) { rightPanel.classList.add('ge-right-panel--dock');
|
||||||
dock = buildDock({
|
dock = buildDock({
|
||||||
entries: [
|
entries: [
|
||||||
...(Array.isArray(extraPanels) ? extraPanels : []),
|
...(Array.isArray(extraPanels) ? extraPanels : []),
|
||||||
@@ -241,12 +239,11 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
onCloseGroup,
|
onCloseGroup,
|
||||||
});
|
});
|
||||||
rightPanel.appendChild(dock.dock);
|
rightPanel.appendChild(dock.dock);
|
||||||
if (!useDock) dock.dock.style.display = 'none';
|
// Layers stays pinned below the dock so the layer list is always
|
||||||
|
// visible while editing (Photoshop-style), regardless of the active
|
||||||
|
// dock tab. Only the tool options + studio panels live in tabs.
|
||||||
|
rightPanel.appendChild(layerPanel);
|
||||||
}
|
}
|
||||||
if (useDock) {
|
|
||||||
rightPanel.classList.add('ge-right-panel--dock');
|
|
||||||
}
|
|
||||||
rightPanel.appendChild(layerPanel);
|
|
||||||
|
|
||||||
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
// Horizontal drag handle on the LEFT edge of the right panel — drag
|
||||||
// left to widen, right to narrow. Persists chosen width in
|
// left to widen, right to narrow. Persists chosen width in
|
||||||
@@ -289,5 +286,5 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = []
|
|||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { rightPanel, controls, layerPanel, panelResize, dock, mobilePanels };
|
return { rightPanel, controls, layerPanel, panelResize, dock };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,8 +109,6 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
|||||||
|
|
||||||
const closeAllFlyouts = () => {
|
const closeAllFlyouts = () => {
|
||||||
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
|
toolbar.querySelectorAll('.ge-tool-slot.open').forEach(s => s.classList.remove('open'));
|
||||||
toolbar.classList.remove('ge-flyout-open');
|
|
||||||
toolbar.closest('.ge-toolbar-wrap')?.classList.remove('ge-flyout-open');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openFlyout = (slotEl, face) => {
|
const openFlyout = (slotEl, face) => {
|
||||||
@@ -118,8 +116,6 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
|||||||
closeAllFlyouts();
|
closeAllFlyouts();
|
||||||
if (isOpen) return;
|
if (isOpen) return;
|
||||||
slotEl.classList.add('open');
|
slotEl.classList.add('open');
|
||||||
toolbar.classList.add('ge-flyout-open');
|
|
||||||
toolbar.closest('.ge-toolbar-wrap')?.classList.add('ge-flyout-open');
|
|
||||||
const flyout = slotEl.querySelector('.ge-tool-flyout');
|
const flyout = slotEl.querySelector('.ge-tool-flyout');
|
||||||
if (!flyout) return;
|
if (!flyout) return;
|
||||||
const r = face.getBoundingClientRect();
|
const r = face.getBoundingClientRect();
|
||||||
@@ -246,13 +242,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
|||||||
: soft >= 70
|
: soft >= 70
|
||||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
|
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.3"/><circle cx="12" cy="12" r="6.5"/></svg>'
|
||||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
|
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="12" cy="12" r="6.5" fill="currentColor" opacity="0.65"/><circle cx="12" cy="12" r="6.5"/></svg>';
|
||||||
const icon = document.createElement('span');
|
presetItem.insertAdjacentHTML('beforeend', `<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>`);
|
||||||
icon.className = 'ge-tool-icon';
|
|
||||||
icon.innerHTML = presetIcon;
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'ge-tool-flyout-label';
|
|
||||||
label.textContent = preset.name;
|
|
||||||
presetItem.append(icon, label);
|
|
||||||
presetItem.addEventListener('click', (ev) => {
|
presetItem.addEventListener('click', (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setInner(face, 'brush');
|
setInner(face, 'brush');
|
||||||
|
|||||||
@@ -535,7 +535,6 @@ export function buildTopbar() {
|
|||||||
<span class="ge-stacked-glyph">
|
<span class="ge-stacked-glyph">
|
||||||
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
||||||
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
<svg hidden class="ge-save-state-icon ge-save-state-error" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
|
<span class="ge-stacked-label ge-save-action-label">SAVE</span>
|
||||||
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
||||||
|
|||||||
@@ -8,29 +8,8 @@
|
|||||||
* @param {HTMLCanvasElement} canvas
|
* @param {HTMLCanvasElement} canvas
|
||||||
* @returns {{x: number, y: number}}
|
* @returns {{x: number, y: number}}
|
||||||
*/
|
*/
|
||||||
import { state } from './state.js';
|
|
||||||
|
|
||||||
let _cachedCanvas = null;
|
|
||||||
let _cachedRect = null;
|
|
||||||
let _cachedRectTime = 0;
|
|
||||||
|
|
||||||
export function invalidateCanvasCoordsCache() {
|
|
||||||
_cachedCanvas = null;
|
|
||||||
_cachedRect = null;
|
|
||||||
_cachedRectTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canvasCoords(e, canvas) {
|
export function canvasCoords(e, canvas) {
|
||||||
const now = performance.now();
|
const rect = canvas.getBoundingClientRect();
|
||||||
let rect;
|
|
||||||
if (_cachedCanvas === canvas && _cachedRect && (state.drawing || (now - _cachedRectTime < 64))) {
|
|
||||||
rect = _cachedRect;
|
|
||||||
} else {
|
|
||||||
rect = canvas.getBoundingClientRect();
|
|
||||||
_cachedCanvas = canvas;
|
|
||||||
_cachedRect = rect;
|
|
||||||
_cachedRectTime = now;
|
|
||||||
}
|
|
||||||
const scaleX = canvas.width / rect.width;
|
const scaleX = canvas.width / rect.width;
|
||||||
const scaleY = canvas.height / rect.height;
|
const scaleY = canvas.height / rect.height;
|
||||||
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
* }} ctx
|
* }} ctx
|
||||||
*/
|
*/
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { invalidateCanvasCoordsCache } from './canvas-coords.js';
|
|
||||||
import {
|
import {
|
||||||
applyCanvasPan,
|
applyCanvasPan,
|
||||||
isDirectPanIntent,
|
isDirectPanIntent,
|
||||||
@@ -59,12 +58,6 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
canvasWindowBindings?.abort();
|
canvasWindowBindings?.abort();
|
||||||
canvasWindowBindings = new AbortController();
|
canvasWindowBindings = new AbortController();
|
||||||
const { signal } = canvasWindowBindings;
|
const { signal } = canvasWindowBindings;
|
||||||
|
|
||||||
// Invalidate coordinate cache on layout, scroll, resize or orientation shifts
|
|
||||||
window.addEventListener('resize', invalidateCanvasCoordsCache, { signal, passive: true });
|
|
||||||
window.addEventListener('scroll', invalidateCanvasCoordsCache, { signal, passive: true });
|
|
||||||
window.addEventListener('orientationchange', invalidateCanvasCoordsCache, { signal, passive: true });
|
|
||||||
canvasArea.addEventListener('scroll', invalidateCanvasCoordsCache, { passive: true });
|
|
||||||
let suppressMouseUntil = 0;
|
let suppressMouseUntil = 0;
|
||||||
// Tool-aware right-click menu on the image and the surrounding area.
|
// Tool-aware right-click menu on the image and the surrounding area.
|
||||||
wireCanvasContextMenu(canvasArea, { signal });
|
wireCanvasContextMenu(canvasArea, { signal });
|
||||||
@@ -97,7 +90,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
window.addEventListener('mousemove', (e) => {
|
window.addEventListener('mousemove', (e) => {
|
||||||
if (Date.now() < suppressMouseUntil) return;
|
if (Date.now() < suppressMouseUntil) return;
|
||||||
// Only shield the raw-coordinate selection/crop rects from chrome.
|
// Only shield the raw-coordinate selection/crop rects from chrome.
|
||||||
if (directGestureActive() && isUiChrome(e)) return;
|
if (isUiChrome(e) && directGestureActive()) return;
|
||||||
continueDraw(e);
|
continueDraw(e);
|
||||||
}, { signal });
|
}, { signal });
|
||||||
window.addEventListener('mouseup', (e) => {
|
window.addEventListener('mouseup', (e) => {
|
||||||
@@ -247,14 +240,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
|||||||
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
|
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
|
||||||
if (Math.abs(newZoom - state.zoom) > 0.001) {
|
if (Math.abs(newZoom - state.zoom) > 0.001) {
|
||||||
state.zoom = newZoom;
|
state.zoom = newZoom;
|
||||||
// A pinch is an explicit zoom; layout refits must not undo it.
|
|
||||||
state.zoomViewMode = 'scale';
|
|
||||||
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
||||||
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
||||||
const label = state.container.querySelector('.ge-zoom-label');
|
const label = state.container.querySelector('.ge-zoom-label');
|
||||||
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
||||||
syncZoomControls?.();
|
syncZoomControls?.();
|
||||||
invalidateCanvasCoordsCache();
|
|
||||||
onViewportChange?.();
|
onViewportChange?.();
|
||||||
}
|
}
|
||||||
const dx = info.cx - multiStartCenter.x;
|
const dx = info.cx - multiStartCenter.x;
|
||||||
|
|||||||
@@ -77,8 +77,7 @@ export function wireClipboardAndDrop({
|
|||||||
// inside the gallery modal's role="dialog" modal-content, so that would
|
// inside the gallery modal's role="dialog" modal-content, so that would
|
||||||
// swallow every paste (including the internal Clipboard + Ctrl+V).
|
// swallow every paste (including the internal Clipboard + Ctrl+V).
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
// A paste dispatched at window has a non-Node target; contains() would throw.
|
const insideEditor = target && container && container.contains(target);
|
||||||
const insideEditor = target instanceof Node && !!container && container.contains(target);
|
|
||||||
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
|
if (insideEditor && (target.isContentEditable || target.closest?.('input, textarea, select'))) return;
|
||||||
|
|
||||||
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
import { drawHistogram } from './histogram.js';
|
import { drawHistogram } from './histogram.js';
|
||||||
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
|
import { parseCubeLut, generateCubeLut } from './cube-lut.js';
|
||||||
|
|
||||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null, schedulePersist = null }) {
|
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
|
||||||
function suppressLayerGhostTap() {
|
function suppressLayerGhostTap() {
|
||||||
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
||||||
}
|
}
|
||||||
@@ -511,7 +511,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
composite();
|
composite();
|
||||||
renderLayerPanel();
|
renderLayerPanel();
|
||||||
closeAdjPopup();
|
closeAdjPopup();
|
||||||
schedulePersist?.();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,14 +535,8 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
||||||
return p.channels[channel];
|
return p.channels[channel];
|
||||||
};
|
};
|
||||||
// Params come from project files and drafts, which only validate the
|
|
||||||
// adjustment type; coerce anything interpolated into markup below.
|
|
||||||
const finite = value => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
|
||||||
const hexColor = (value, fallback) => (/^#[0-9a-f]{6}$/i.test(value) ? value : fallback);
|
|
||||||
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||||
const sliderRow = (key, label, min, max, rawValue, suffix, defaultValue) => {
|
const sliderRow = (key, label, min, max, value, suffix, defaultValue) => `
|
||||||
const value = finite(rawValue);
|
|
||||||
return `
|
|
||||||
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
|
<div class="ge-adj-row" data-adj-key="${key}"${defaultValue != null ? ` data-default="${defaultValue}"` : ''}>
|
||||||
<label>${label}</label>
|
<label>${label}</label>
|
||||||
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
|
||||||
@@ -551,7 +544,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
|
||||||
if (type === 'brightness-contrast') {
|
if (type === 'brightness-contrast') {
|
||||||
const bSlider = Math.round((p.brightness - 1) * 100);
|
const bSlider = Math.round((p.brightness - 1) * 100);
|
||||||
const cSlider = Math.round((p.contrast - 1) * 100);
|
const cSlider = Math.round((p.contrast - 1) * 100);
|
||||||
@@ -700,7 +692,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
});
|
});
|
||||||
} else if (type === 'color-balance') {
|
} else if (type === 'color-balance') {
|
||||||
// Color-tinted slider ends so the user sees what direction does what.
|
// Color-tinted slider ends so the user sees what direction does what.
|
||||||
const cbRow = (key, leftCol, rightCol, label, rawValue, value = finite(rawValue)) => `
|
const cbRow = (key, leftCol, rightCol, label, value) => `
|
||||||
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
|
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
|
||||||
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
|
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
|
||||||
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
|
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
|
||||||
@@ -757,9 +749,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
buildAdjBody(layer, type, body, popEl);
|
buildAdjBody(layer, type, body, popEl);
|
||||||
});
|
});
|
||||||
} else if (type === 'gradient-map') {
|
} else if (type === 'gradient-map') {
|
||||||
const gradientDefaults = defaultAdjParams('gradient-map');
|
|
||||||
p.shadows = hexColor(p.shadows, gradientDefaults.shadows);
|
|
||||||
p.highlights = hexColor(p.highlights, gradientDefaults.highlights);
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
||||||
<div class="ge-gradient-color-row">
|
<div class="ge-gradient-color-row">
|
||||||
@@ -800,7 +789,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="ge-adj-row">
|
<div class="ge-adj-row">
|
||||||
<label>Color</label>
|
<label>Color</label>
|
||||||
<input type="color" data-photo-key="color" value="${hexColor(p.color, '#ec8a00')}">
|
<input type="color" data-photo-key="color" value="${p.color || '#ec8a00'}">
|
||||||
</div>
|
</div>
|
||||||
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
|
${sliderRow('density', 'Density', 0, 100, Math.round(p.density ?? 25), '%')}
|
||||||
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
<label class="ge-adj-toggle-row"><span>Preserve luminosity</span><input type="checkbox" data-photo-preserve${p.preserve_luminosity ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||||
@@ -849,7 +838,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
|
const presetLabels = { none: 'None', teal_orange: 'Teal & Orange', cold: 'Cold', warm: 'Warm', high_contrast: 'High Contrast', sepia: 'Sepia' };
|
||||||
const strength = p.strength == null ? 100 : p.strength;
|
const strength = p.strength == null ? 100 : p.strength;
|
||||||
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
|
const hasCube = !!(p.cube && p.cube.size && p.cube.data);
|
||||||
const lutName = hasCube ? `Loaded LUT · ${finite(p.cube.size)}³` : 'No .cube loaded';
|
const lutName = hasCube ? `Loaded LUT · ${p.cube.size}³` : 'No .cube loaded';
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<label class="ge-adj-channel-field">Lookup preset
|
<label class="ge-adj-channel-field">Lookup preset
|
||||||
<select class="ge-adj-lut-select">
|
<select class="ge-adj-lut-select">
|
||||||
@@ -983,8 +972,6 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, g
|
|||||||
p.strength = raw; display = raw + '%';
|
p.strength = raw; display = raw + '%';
|
||||||
} else if (type === 'white-balance') {
|
} else if (type === 'white-balance') {
|
||||||
p[key] = raw;
|
p[key] = raw;
|
||||||
} else if (type === 'vibrance') {
|
|
||||||
p[key] = raw;
|
|
||||||
} else if (type === 'black-white') {
|
} else if (type === 'black-white') {
|
||||||
p[key] = raw;
|
p[key] = raw;
|
||||||
} else if (type === 'shadows-highlights') {
|
} else if (type === 'shadows-highlights') {
|
||||||
|
|||||||
@@ -1948,8 +1948,7 @@ export function createLayerPanelRenderer(deps) {
|
|||||||
});
|
});
|
||||||
const sName = document.createElement('span');
|
const sName = document.createElement('span');
|
||||||
sName.className = 'ge-layer-name ge-adj-sub-name';
|
sName.className = 'ge-layer-name ge-adj-sub-name';
|
||||||
sName.innerHTML = '<span class="ge-adj-sub-icon">◌</span><span></span>';
|
sName.innerHTML = `<span class="ge-adj-sub-icon">◌</span><span>${effectLabel(effect.type)}</span>`;
|
||||||
sName.lastElementChild.textContent = effectLabel(effect.type);
|
|
||||||
sName.title = 'Edit effect (drag onto another layer to copy)';
|
sName.title = 'Edit effect (drag onto another layer to copy)';
|
||||||
sName.addEventListener('click', event => {
|
sName.addEventListener('click', event => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ const GRADIENTS = [
|
|||||||
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
|
['Spectrum', ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']],
|
||||||
];
|
];
|
||||||
|
|
||||||
function el(tag, className, text) {
|
function el(tag, className, html) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
if (className) node.className = className;
|
if (className) node.className = className;
|
||||||
if (text != null) node.textContent = text;
|
if (html != null) node.innerHTML = html;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,11 +274,7 @@ export function createStudioPanels({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shapeLayers.slice().reverse().forEach(layer => {
|
shapeLayers.slice().reverse().forEach(layer => {
|
||||||
const button = el('button', 'ge-channel-row');
|
const button = el('button', 'ge-channel-row', `<span class="ge-channel-name">${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}</span>`);
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'ge-channel-name';
|
|
||||||
name.textContent = `${layer.shape.type === 'path' ? 'Path' : layer.shape.type} · ${layer.name || layer.id.slice(0, 6)}`;
|
|
||||||
button.appendChild(name);
|
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
|
button.addEventListener('click', () => { onSelectLayer(layer.id); renderPaths(); });
|
||||||
pathBody.appendChild(button);
|
pathBody.appendChild(button);
|
||||||
|
|||||||
@@ -400,15 +400,10 @@ export const state = {
|
|||||||
// Loading-overlay element (whirlpool + label).
|
// Loading-overlay element (whirlpool + label).
|
||||||
editorLoadingEl: null,
|
editorLoadingEl: null,
|
||||||
|
|
||||||
// ── Draft persistence & revision tracking ──
|
// ── Draft persistence ──
|
||||||
draftId: null,
|
draftId: null,
|
||||||
draftName: '',
|
draftName: '',
|
||||||
documentId: null,
|
documentId: null,
|
||||||
documentRevision: 0,
|
|
||||||
persistingRevision: 0,
|
|
||||||
savedRevision: 0,
|
|
||||||
persistRetryTimer: null,
|
|
||||||
persistRetryCount: 0,
|
|
||||||
persistTimer: null,
|
persistTimer: null,
|
||||||
// Current PUT/POST promise so concurrent saves can chain.
|
// Current PUT/POST promise so concurrent saves can chain.
|
||||||
persistInFlight: null,
|
persistInFlight: null,
|
||||||
|
|||||||
@@ -12,21 +12,15 @@ function toolSettings(tool) {
|
|||||||
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
|
return { opacity: state.brushOpacity, flow: state.brushFlow, softness: state.brushSoftness };
|
||||||
}
|
}
|
||||||
|
|
||||||
let _lastColor = null;
|
|
||||||
let _lastTransparentColor = null;
|
|
||||||
function transparentColor(color) {
|
function transparentColor(color) {
|
||||||
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
|
||||||
_lastColor = color;
|
|
||||||
if (color.startsWith('#')) {
|
if (color.startsWith('#')) {
|
||||||
const raw = color.slice(1);
|
const raw = color.slice(1);
|
||||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||||
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
|
return `rgba(${rgb.join(',')},0)`;
|
||||||
return _lastTransparentColor;
|
|
||||||
}
|
}
|
||||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||||
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
return match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||||
return _lastTransparentColor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||||
|
|||||||
@@ -10,16 +10,6 @@
|
|||||||
* the Window > Workspace submenu (wire-extra-menus.js).
|
* the Window > Workspace submenu (wire-extra-menus.js).
|
||||||
*/
|
*/
|
||||||
export function wireSubmenuToggles(menu) {
|
export function wireSubmenuToggles(menu) {
|
||||||
menu.addEventListener('mouseover', (e) => {
|
|
||||||
if (e.target.closest('.ge-menu-parent, .ge-submenu')) {
|
|
||||||
menu.classList.add('ge-flyout-open');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
menu.addEventListener('mouseleave', () => {
|
|
||||||
if (!menu.querySelector('.ge-menu-parent.open')) {
|
|
||||||
menu.classList.remove('ge-flyout-open');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
menu.addEventListener('click', (e) => {
|
menu.addEventListener('click', (e) => {
|
||||||
const parent = e.target.closest('.ge-menu-parent');
|
const parent = e.target.closest('.ge-menu-parent');
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
|
|||||||
@@ -241,13 +241,11 @@ export function createTransformSession({
|
|||||||
}
|
}
|
||||||
if (state.transformPopup) {
|
if (state.transformPopup) {
|
||||||
try {
|
try {
|
||||||
state.transformPopup.closest('.ge-controls')?.classList.remove('ge-transform-active');
|
|
||||||
state.transformPopup.innerHTML = '';
|
state.transformPopup.innerHTML = '';
|
||||||
state.transformPopup.style.display = 'none';
|
state.transformPopup.style.display = 'none';
|
||||||
} catch {}
|
} catch {}
|
||||||
state.transformPopup = null;
|
state.transformPopup = null;
|
||||||
}
|
}
|
||||||
document.querySelectorAll('.ge-controls.ge-transform-active').forEach(c => c.classList.remove('ge-transform-active'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mobileBoundsHandler = null;
|
let mobileBoundsHandler = null;
|
||||||
@@ -287,11 +285,6 @@ export function createTransformSession({
|
|||||||
// bar) instead of a floating popup over the canvas.
|
// bar) instead of a floating popup over the canvas.
|
||||||
const pop = document.getElementById('ge-transform-section');
|
const pop = document.getElementById('ge-transform-section');
|
||||||
if (!pop) return;
|
if (!pop) return;
|
||||||
const parentControls = pop.closest('.ge-controls');
|
|
||||||
if (parentControls) {
|
|
||||||
parentControls.classList.add('ge-transform-active');
|
|
||||||
parentControls.classList.remove('dismissed');
|
|
||||||
}
|
|
||||||
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
|
pop.innerHTML = `<div class="ge-transform-popup">${transformPopupHTML()}</div>`;
|
||||||
const title = pop.querySelector('.ge-adj-title');
|
const title = pop.querySelector('.ge-adj-title');
|
||||||
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
|
if (title && state.transformTarget === 'selection') title.textContent = 'Transform Selection';
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export function wireTopbar(deps) {
|
|||||||
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
|
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
|
||||||
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
|
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
|
||||||
});
|
});
|
||||||
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
|
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
|
||||||
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); state.zoomViewMode = 'scale'; applyZoom(); });
|
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
|
||||||
|
|
||||||
// Export / Download / Project Save / Project Load.
|
// Export / Download / Project Save / Project Load.
|
||||||
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
|
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
|
||||||
|
|||||||
@@ -5021,7 +5021,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
|
|||||||
} catch (_) { _syncUnreadTabBadge(0); }
|
} catch (_) { _syncUnreadTabBadge(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
|
export async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||||
const seq = ++_libLoadSeq;
|
const seq = ++_libLoadSeq;
|
||||||
state._libLoading = true;
|
state._libLoading = true;
|
||||||
const accountAtStart = state._libAccountId || '';
|
const accountAtStart = state._libAccountId || '';
|
||||||
@@ -5119,7 +5119,7 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
|
|||||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||||
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
|
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
|
||||||
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
|
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
|
||||||
if (!cached && cacheable && !force && !skipFastPath) {
|
if (!cached && cacheable && !force) {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||||
try {
|
try {
|
||||||
|
|||||||
+8
-57
@@ -2229,36 +2229,6 @@ function _makeGalleryDraggable(content) {
|
|||||||
// Re-export the manager for the rail click handler
|
// Re-export the manager for the rail click handler
|
||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
|
|
||||||
// Module-owned listeners resolve the current picker; closed modals are never retained.
|
|
||||||
window.addEventListener('ge-ai-command-model-change', (e) => {
|
|
||||||
const imageModelSelect = document.getElementById('gallery-image-model');
|
|
||||||
if (!imageModelSelect || !imageModelSelect._modelsReady) return;
|
|
||||||
let val = (e && e.detail && typeof e.detail.value === 'string') ? e.detail.value : '';
|
|
||||||
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
|
|
||||||
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
|
|
||||||
val = '';
|
|
||||||
}
|
|
||||||
if (imageModelSelect.value !== val) {
|
|
||||||
imageModelSelect.value = val;
|
|
||||||
if (imageModelSelect.value !== val) imageModelSelect.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener('storage', (e) => {
|
|
||||||
const imageModelSelect = document.getElementById('gallery-image-model');
|
|
||||||
if (e && e.key === 'ge-ai-command-model' && imageModelSelect?._modelsReady) {
|
|
||||||
let val = e.newValue || '';
|
|
||||||
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
|
|
||||||
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
|
|
||||||
val = '';
|
|
||||||
}
|
|
||||||
if (imageModelSelect.value !== val) {
|
|
||||||
imageModelSelect.value = val;
|
|
||||||
if (imageModelSelect.value !== val) imageModelSelect.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export function openGallery() {
|
export function openGallery() {
|
||||||
// If already minimized — restore in place, preserve all state
|
// If already minimized — restore in place, preserve all state
|
||||||
if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) {
|
if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) {
|
||||||
@@ -2637,7 +2607,7 @@ export function openGallery() {
|
|||||||
|
|
||||||
// ── Image model (Settings tab) ──
|
// ── Image model (Settings tab) ──
|
||||||
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
|
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
|
||||||
// and the SAME localStorage key `ge-ai-command-model` (value `<endpoint_id>::<id>`)
|
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
|
||||||
// so the two stay in sync.
|
// so the two stay in sync.
|
||||||
const _GE_MODEL_KEY = 'ge-ai-command-model';
|
const _GE_MODEL_KEY = 'ge-ai-command-model';
|
||||||
const imageModelSelect = document.getElementById('gallery-image-model');
|
const imageModelSelect = document.getElementById('gallery-image-model');
|
||||||
@@ -2647,8 +2617,7 @@ export function openGallery() {
|
|||||||
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
|
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
|
||||||
const data = r.ok ? await r.json() : null;
|
const data = r.ok ? await r.json() : null;
|
||||||
const models = (data && Array.isArray(data.models)) ? data.models : [];
|
const models = (data && Array.isArray(data.models)) ? data.models : [];
|
||||||
const rawSaved = localStorage.getItem(_GE_MODEL_KEY) || '';
|
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
|
||||||
let saved = rawSaved;
|
|
||||||
imageModelSelect.innerHTML = '';
|
imageModelSelect.innerHTML = '';
|
||||||
const def = document.createElement('option');
|
const def = document.createElement('option');
|
||||||
def.value = '';
|
def.value = '';
|
||||||
@@ -2656,34 +2625,12 @@ export function openGallery() {
|
|||||||
imageModelSelect.appendChild(def);
|
imageModelSelect.appendChild(def);
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
|
opt.value = `${m.base_url || ''}::${m.id}`;
|
||||||
opt.value = endpointKey ? `${endpointKey}::${m.id}` : m.id;
|
|
||||||
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
|
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
|
||||||
imageModelSelect.appendChild(opt);
|
imageModelSelect.appendChild(opt);
|
||||||
}
|
}
|
||||||
imageModelSelect._modelsReady = true;
|
|
||||||
// Invalidate or migrate legacy localStorage values containing URLs or leading ::
|
|
||||||
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
|
||||||
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
|
||||||
const matching = models.filter(m => m.id === modelPart);
|
|
||||||
if (matching.length === 1) {
|
|
||||||
const epKey = matching[0].endpoint_id ? String(matching[0].endpoint_id) : (matching[0].source === 'local' ? 'local' : '');
|
|
||||||
saved = epKey ? `${epKey}::${matching[0].id}` : matching[0].id;
|
|
||||||
} else {
|
|
||||||
saved = '';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (saved) localStorage.setItem(_GE_MODEL_KEY, saved);
|
|
||||||
else localStorage.removeItem(_GE_MODEL_KEY);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
imageModelSelect.value = saved;
|
imageModelSelect.value = saved;
|
||||||
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
|
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
|
||||||
if (imageModelSelect.value !== rawSaved) {
|
|
||||||
if (imageModelSelect.value) localStorage.setItem(_GE_MODEL_KEY, imageModelSelect.value);
|
|
||||||
else localStorage.removeItem(_GE_MODEL_KEY);
|
|
||||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: imageModelSelect.value } }));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
|
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
|
||||||
}
|
}
|
||||||
@@ -2698,7 +2645,11 @@ export function openGallery() {
|
|||||||
});
|
});
|
||||||
_fillImageModels();
|
_fillImageModels();
|
||||||
}
|
}
|
||||||
|
window.addEventListener('storage', (e) => {
|
||||||
|
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
|
||||||
|
imageModelSelect.value = e.newValue || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Tag All Untagged ──
|
// ── Tag All Untagged ──
|
||||||
let _tagCancelRequested = false;
|
let _tagCancelRequested = false;
|
||||||
|
|||||||
+134
-366
@@ -7,7 +7,7 @@ import dragSortModule from './dragSort.js';
|
|||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||||
import modalManager from './modalManager.js';
|
import modalManager from './modalManager.js';
|
||||||
import { canvasCoords as _canvasCoords, invalidateCanvasCoordsCache } from './editor/canvas-coords.js';
|
import { canvasCoords as _canvasCoords } from './editor/canvas-coords.js';
|
||||||
import { pickTopmostLayerAt as _pickTopmostLayerAt } from './editor/layer-hit-test.js';
|
import { pickTopmostLayerAt as _pickTopmostLayerAt } from './editor/layer-hit-test.js';
|
||||||
import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js';
|
import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js';
|
||||||
import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js';
|
import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js';
|
||||||
@@ -854,13 +854,11 @@ function _setAiProgress(frac, label) {
|
|||||||
if (lbl) lbl.textContent = label || '';
|
if (lbl) lbl.textContent = label || '';
|
||||||
}
|
}
|
||||||
function _startAiProgress(requestId) {
|
function _startAiProgress(requestId) {
|
||||||
const endpointId = arguments[1] || '';
|
|
||||||
_stopAiProgress();
|
_stopAiProgress();
|
||||||
if (!requestId) return;
|
if (!requestId) return;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
try {
|
try {
|
||||||
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
|
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}`, { credentials: 'same-origin' });
|
||||||
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
|
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const p = await r.json();
|
const p = await r.json();
|
||||||
const st = p.stage || '';
|
const st = p.stage || '';
|
||||||
@@ -1115,68 +1113,6 @@ function _matchAiCommandSuggestions(query) {
|
|||||||
.map(hit => hit.item);
|
.map(hit => hit.item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On phones the layers panel is a bottom sheet that can cover the editor
|
|
||||||
// footer (zoom / setup). Float the footer just above the sheet's top edge so
|
|
||||||
// its controls stay tappable; restore it to normal flow when the sheet is
|
|
||||||
// collapsed or on desktop.
|
|
||||||
let _lastFooterDelta = 0;
|
|
||||||
const _placeMobileFooter = () => {
|
|
||||||
const footer = document.querySelector('.ge-editor-footer');
|
|
||||||
if (!footer) return;
|
|
||||||
const area = document.querySelector('.ge-canvas-area');
|
|
||||||
const _clear = () => {
|
|
||||||
footer.classList.remove('ge-footer-floating');
|
|
||||||
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
|
|
||||||
footer.style.bottom = ''; footer.style.zIndex = '';
|
|
||||||
document.body.classList.remove('ge-footer-docked');
|
|
||||||
state._footerDocked = false;
|
|
||||||
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
|
|
||||||
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
|
|
||||||
_lastFooterDelta = 0;
|
|
||||||
// The sheet may have squeezed fit view to a few pixels. Restore fit
|
|
||||||
// after releasing that constraint, while retaining an explicit zoom.
|
|
||||||
if (state.zoomViewMode === 'fit') {
|
|
||||||
try { _fitZoom(); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (window.innerWidth > 700) { _clear(); return; }
|
|
||||||
const sheet = document.querySelector('.ge-right-panel');
|
|
||||||
if (!sheet || sheet.classList.contains('minimized') || sheet.classList.contains('expanded')) { _clear(); return; }
|
|
||||||
const r = sheet.getBoundingClientRect();
|
|
||||||
const vh = window.innerHeight;
|
|
||||||
if (r.height <= 0 || r.top >= vh || r.top <= 140) { _clear(); return; }
|
|
||||||
footer.classList.add('ge-footer-floating');
|
|
||||||
footer.style.position = 'fixed';
|
|
||||||
footer.style.left = '0';
|
|
||||||
footer.style.right = '0';
|
|
||||||
footer.style.zIndex = '60';
|
|
||||||
// Dock the footer flush to the sheet top: no seam / dead space between them.
|
|
||||||
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
|
|
||||||
document.body.classList.add('ge-footer-docked');
|
|
||||||
// Dock the canvas image flush too: with the dock padding off, the image
|
|
||||||
// fills the region so its bottom meets the footer top (no dead space).
|
|
||||||
state._footerDocked = true;
|
|
||||||
// Size the canvas region so it ends exactly where the footer begins:
|
|
||||||
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
|
|
||||||
// would otherwise keep it running under the footer and the sheet.) The
|
|
||||||
// ge-footer-docked class also hides the sheet grab handle.
|
|
||||||
if (area) {
|
|
||||||
const footerH = footer.getBoundingClientRect().height || 0;
|
|
||||||
const desiredBottom = Math.round(r.top - footerH);
|
|
||||||
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
|
|
||||||
if (want !== _lastFooterDelta) {
|
|
||||||
area.style.flex = want ? '0 0 auto' : '';
|
|
||||||
area.style.minHeight = want ? '0px' : '';
|
|
||||||
area.style.height = want ? want + 'px' : '';
|
|
||||||
_lastFooterDelta = want;
|
|
||||||
if (state.zoomViewMode === 'fit') {
|
|
||||||
try { _fitZoom(); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function _wireAiCommandBox() {
|
function _wireAiCommandBox() {
|
||||||
const wrap = document.getElementById('ge-ai-command');
|
const wrap = document.getElementById('ge-ai-command');
|
||||||
const toggle = document.getElementById('ge-ai-command-toggle');
|
const toggle = document.getElementById('ge-ai-command-toggle');
|
||||||
@@ -1432,6 +1368,60 @@ function _wireAiCommandBox() {
|
|||||||
input.addEventListener('click', _trackLift);
|
input.addEventListener('click', _trackLift);
|
||||||
wrap.addEventListener('focusin', _trackLift);
|
wrap.addEventListener('focusin', _trackLift);
|
||||||
requestAnimationFrame(() => requestAnimationFrame(_liftQuickEdit));
|
requestAnimationFrame(() => requestAnimationFrame(_liftQuickEdit));
|
||||||
|
// On phones the layers panel is a bottom sheet that can cover the editor
|
||||||
|
// footer (zoom / setup). Float the footer just above the sheet's top edge so
|
||||||
|
// its controls stay tappable; restore it to normal flow when the sheet is
|
||||||
|
// collapsed or on desktop.
|
||||||
|
let _lastFooterDelta = 0;
|
||||||
|
const _placeMobileFooter = () => {
|
||||||
|
const footer = document.querySelector('.ge-editor-footer');
|
||||||
|
if (!footer) return;
|
||||||
|
const area = document.querySelector('.ge-canvas-area');
|
||||||
|
const _clear = () => {
|
||||||
|
footer.classList.remove('ge-footer-floating');
|
||||||
|
footer.style.position = ''; footer.style.left = ''; footer.style.right = '';
|
||||||
|
footer.style.bottom = ''; footer.style.zIndex = '';
|
||||||
|
document.body.classList.remove('ge-footer-docked');
|
||||||
|
state._footerDocked = false;
|
||||||
|
if (area && (area.style.marginBottom || area.style.height || area.style.flex || area.style.minHeight)) {
|
||||||
|
area.style.marginBottom = ''; area.style.height = ''; area.style.flex = ''; area.style.minHeight = '';
|
||||||
|
_lastFooterDelta = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (window.innerWidth > 700) { _clear(); return; }
|
||||||
|
const sheet = document.querySelector('.ge-right-panel');
|
||||||
|
if (!sheet || sheet.classList.contains('minimized')) { _clear(); return; }
|
||||||
|
const r = sheet.getBoundingClientRect();
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
if (r.height <= 0 || r.top >= vh) { _clear(); return; }
|
||||||
|
footer.classList.add('ge-footer-floating');
|
||||||
|
footer.style.position = 'fixed';
|
||||||
|
footer.style.left = '0';
|
||||||
|
footer.style.right = '0';
|
||||||
|
footer.style.zIndex = '60';
|
||||||
|
// Dock the footer flush to the sheet top: no seam / dead space between them.
|
||||||
|
footer.style.bottom = Math.max(0, Math.round(vh - r.top)) + 'px';
|
||||||
|
document.body.classList.add('ge-footer-docked');
|
||||||
|
// Dock the canvas image flush too: with the dock padding off, the image
|
||||||
|
// fills the region so its bottom meets the footer top (no dead space).
|
||||||
|
state._footerDocked = true;
|
||||||
|
// Size the canvas region so it ends exactly where the footer begins:
|
||||||
|
// canvas bottom == footer top == sheet top. (Its mobile min-height:40vh
|
||||||
|
// would otherwise keep it running under the footer and the sheet.) The
|
||||||
|
// ge-footer-docked class also hides the sheet grab handle.
|
||||||
|
if (area) {
|
||||||
|
const footerH = footer.getBoundingClientRect().height || 0;
|
||||||
|
const desiredBottom = Math.round(r.top - footerH);
|
||||||
|
const want = Math.max(0, Math.round(desiredBottom - area.getBoundingClientRect().top));
|
||||||
|
if (want !== _lastFooterDelta) {
|
||||||
|
area.style.flex = want ? '0 0 auto' : '';
|
||||||
|
area.style.minHeight = want ? '0px' : '';
|
||||||
|
area.style.height = want ? want + 'px' : '';
|
||||||
|
_lastFooterDelta = want;
|
||||||
|
try { _fitZoom(); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
// The layers sheet expands/collapses (and its peek height grows as layers
|
// The layers sheet expands/collapses (and its peek height grows as layers
|
||||||
// render) independently of the keyboard; watch it so the bar and the footer
|
// render) independently of the keyboard; watch it so the bar and the footer
|
||||||
// keep clear of the sheet's top edge as it moves.
|
// keep clear of the sheet's top edge as it moves.
|
||||||
@@ -2602,8 +2592,8 @@ function _wireAiCommandBox() {
|
|||||||
// done vs retry. The caller enforces a single retry (max 1).
|
// done vs retry. The caller enforces a single retry (max 1).
|
||||||
const _vlJudgeAfter = async (prompt, before, after) => {
|
const _vlJudgeAfter = async (prompt, before, after) => {
|
||||||
if (!before || !after) return { done: true };
|
if (!before || !after) return { done: true };
|
||||||
let ep = '';
|
let ep = 'http://100.69.120.65:8097/v1';
|
||||||
let model = 'Qwen3.5-9B';
|
let model = 'Qwen3.5-9B-MLX-8bit';
|
||||||
try {
|
try {
|
||||||
const info = _getSelectedQuickEditAgentModel();
|
const info = _getSelectedQuickEditAgentModel();
|
||||||
if (info && info.endpoint) ep = info.endpoint;
|
if (info && info.endpoint) ep = info.endpoint;
|
||||||
@@ -2647,7 +2637,6 @@ function _wireAiCommandBox() {
|
|||||||
{ type: 'image_url', image_url: { url: after } },
|
{ type: 'image_url', image_url: { url: after } },
|
||||||
] }],
|
] }],
|
||||||
};
|
};
|
||||||
if (!ep) return { done: true };
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(ep.replace(/\/$/, '') + '/chat/completions', {
|
const resp = await fetch(ep.replace(/\/$/, '') + '/chat/completions', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||||
@@ -3306,7 +3295,6 @@ const _adjPopupSystem = createAdjPopupSystem({
|
|||||||
saveState: _saveState,
|
saveState: _saveState,
|
||||||
renderLayerPanel: () => _renderLayerPanel(),
|
renderLayerPanel: () => _renderLayerPanel(),
|
||||||
getAdjustmentSource: layer => _adjustmentSourceCanvas(layer),
|
getAdjustmentSource: layer => _adjustmentSourceCanvas(layer),
|
||||||
schedulePersist: () => _schedulePersist(),
|
|
||||||
});
|
});
|
||||||
const _closeFxPopup = _adjPopupSystem.closeFxPopup;
|
const _closeFxPopup = _adjPopupSystem.closeFxPopup;
|
||||||
const _ensureAdjustments = _adjPopupSystem.ensureAdjustments;
|
const _ensureAdjustments = _adjPopupSystem.ensureAdjustments;
|
||||||
@@ -3960,13 +3948,13 @@ function _finishComposite(render, documentCanvas) {
|
|||||||
if (state.transformActive) _drawTransformHandles();
|
if (state.transformActive) _drawTransformHandles();
|
||||||
else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
|
else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
|
||||||
if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay();
|
if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay();
|
||||||
if (state.tool === 'pen' || state.penPoints?.length) _penTool.draw();
|
_penTool.draw();
|
||||||
if (state.cropRect && !state.cropping) _drawCropOverlay();
|
if (state.cropRect && !state.cropping) _drawCropOverlay();
|
||||||
if (state.cropRect) _syncCropFrame();
|
_syncCropFrame();
|
||||||
if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
|
if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
|
||||||
if (!state.drawing) _precisionGuides?.drawDocumentOverlay(state.mainCtx);
|
_precisionGuides?.drawDocumentOverlay(state.mainCtx);
|
||||||
_refreshSelectionOverlay();
|
_refreshSelectionOverlay();
|
||||||
if (!state.drawing) _syncToolClearIndicators();
|
_syncToolClearIndicators();
|
||||||
}
|
}
|
||||||
|
|
||||||
function composite() {
|
function composite() {
|
||||||
@@ -4345,14 +4333,7 @@ function _saveState(label, options = {}) {
|
|||||||
console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e);
|
console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e);
|
||||||
}
|
}
|
||||||
try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); }
|
try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); }
|
||||||
try {
|
try { _schedulePersist(); } catch (e) { console.error('[gallery] schedulePersist:', e); }
|
||||||
state.documentRevision = (state.documentRevision || 0) + 1;
|
|
||||||
state.persistDirty = true;
|
|
||||||
_setDraftStatus('Unsaved', 'dirty');
|
|
||||||
if (!_draftSavingDisabled()) {
|
|
||||||
_schedulePersist();
|
|
||||||
}
|
|
||||||
} catch (e) { console.error('[gallery] schedulePersist:', e); }
|
|
||||||
try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); }
|
try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4386,47 +4367,20 @@ function _writeActiveEditorSession() {
|
|||||||
function _setDraftStatus(label, stateName = '') {
|
function _setDraftStatus(label, stateName = '') {
|
||||||
const el = document.getElementById('ge-draft-status');
|
const el = document.getElementById('ge-draft-status');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const validStates = ['saved', 'dirty', 'pending', 'saving', 'error', 'paused'];
|
const normalizedState = stateName === 'saved' ? 'saved' : 'dirty';
|
||||||
let normalizedState = stateName;
|
const normalizedLabel = normalizedState === 'saved' ? 'Saved' : 'Unsaved';
|
||||||
if (!validStates.includes(normalizedState)) {
|
|
||||||
if (stateName === 'saved' || label === 'Saved') normalizedState = 'saved';
|
|
||||||
else if (stateName === 'error' || /fail|error/i.test(label || '')) normalizedState = 'error';
|
|
||||||
else if (stateName === 'paused' || /paused/i.test(label || '')) normalizedState = 'paused';
|
|
||||||
else if (stateName === 'saving' || label === 'Saving…' || label === 'Saving...') normalizedState = 'saving';
|
|
||||||
else if (stateName === 'pending' || /saving soon/i.test(label || '')) normalizedState = 'pending';
|
|
||||||
else normalizedState = 'dirty';
|
|
||||||
}
|
|
||||||
const defaultLabels = {
|
|
||||||
saved: 'Saved',
|
|
||||||
dirty: 'Unsaved',
|
|
||||||
pending: 'Saving soon…',
|
|
||||||
saving: 'Saving…',
|
|
||||||
error: 'Save failed',
|
|
||||||
paused: 'Save paused',
|
|
||||||
};
|
|
||||||
const normalizedLabel = label || defaultLabels[normalizedState] || 'Unsaved';
|
|
||||||
el.textContent = normalizedLabel;
|
el.textContent = normalizedLabel;
|
||||||
el.dataset.state = normalizedState;
|
el.dataset.state = stateName;
|
||||||
const iconHost = el.closest('#ge-save-menu-btn');
|
const iconHost = el.closest('#ge-save-menu-btn');
|
||||||
const iconKind = (normalizedState === 'error' || normalizedState === 'paused')
|
|
||||||
? 'error'
|
|
||||||
: (normalizedState === 'saved' ? 'saved' : 'dirty');
|
|
||||||
// The icons are <svg>; SVGElement has no `hidden` property, so set the attribute.
|
|
||||||
iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => {
|
iconHost?.querySelectorAll('.ge-save-state-icon').forEach(icon => {
|
||||||
icon.toggleAttribute('hidden', !icon.classList.contains(`ge-save-state-${iconKind}`));
|
icon.hidden = !icon.classList.contains(`ge-save-state-${normalizedState}`);
|
||||||
});
|
});
|
||||||
const saveButton = document.getElementById('ge-save-menu-btn');
|
const saveButton = document.getElementById('ge-save-menu-btn');
|
||||||
if (saveButton) {
|
if (saveButton) {
|
||||||
if (normalizedState === 'error' || normalizedState === 'paused') {
|
saveButton.title = normalizedState === 'error' ? 'Draft autosave needs attention' : `Draft status: ${normalizedLabel}`;
|
||||||
saveButton.title = state.persistErrorMessage || `Draft autosave failed: ${normalizedLabel}`;
|
saveButton.setAttribute('aria-label', normalizedLabel);
|
||||||
saveButton.setAttribute('aria-label', `Draft autosave failed: ${normalizedLabel}`);
|
|
||||||
} else {
|
|
||||||
saveButton.title = `Draft status: ${normalizedLabel}`;
|
|
||||||
saveButton.setAttribute('aria-label', `Draft status: ${normalizedLabel}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try { window.__geSetDraftStatus = _setDraftStatus; } catch (_) {}
|
|
||||||
|
|
||||||
function _clearActiveEditorSession() {
|
function _clearActiveEditorSession() {
|
||||||
try { sessionStorage.removeItem(ACTIVE_EDITOR_SESSION_KEY); } catch (_) {}
|
try { sessionStorage.removeItem(ACTIVE_EDITOR_SESSION_KEY); } catch (_) {}
|
||||||
@@ -4449,20 +4403,11 @@ function _draftSavingDisabled() {
|
|||||||
function _schedulePersist() {
|
function _schedulePersist() {
|
||||||
if (_draftSavingDisabled()) return;
|
if (_draftSavingDisabled()) return;
|
||||||
if (!state.editorOpen || !state.layers.length) return;
|
if (!state.editorOpen || !state.layers.length) return;
|
||||||
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
if (state.persistTimer) clearTimeout(state.persistTimer);
|
||||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
_setDraftStatus('Saving soon…', 'pending');
|
||||||
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
|
|
||||||
// The first save establishes the recovery anchor. Start it immediately
|
// The first save establishes the recovery anchor. Start it immediately
|
||||||
// when no server draft exists; subsequent edits can stay debounced.
|
// when no server draft exists; subsequent edits can stay debounced.
|
||||||
const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0;
|
const delay = state.draftId ? PERSIST_DEBOUNCE_MS : 0;
|
||||||
if (delay > 0) {
|
|
||||||
state.pendingStatusTimer = setTimeout(() => {
|
|
||||||
state.pendingStatusTimer = null;
|
|
||||||
if (state.editorOpen) _setDraftStatus('Saving soon…', 'pending');
|
|
||||||
}, Math.min(200, Math.floor(delay / 2)));
|
|
||||||
} else {
|
|
||||||
_setDraftStatus('Saving soon…', 'pending');
|
|
||||||
}
|
|
||||||
state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay);
|
state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4646,8 +4591,6 @@ async function _persistDraft() {
|
|||||||
// Coalesce concurrent saves — if one's already in-flight, mark dirty
|
// Coalesce concurrent saves — if one's already in-flight, mark dirty
|
||||||
// and let the running call kick off another when it returns.
|
// and let the running call kick off another when it returns.
|
||||||
if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; }
|
if (state.persistInFlight) { state.persistDirty = true; _setDraftStatus('Saving…', 'saving'); return; }
|
||||||
const currentRevision = state.documentRevision || 0;
|
|
||||||
state.persistingRevision = currentRevision;
|
|
||||||
const sessionToken = state.editorSessionToken;
|
const sessionToken = state.editorSessionToken;
|
||||||
const draftIdAtStart = state.draftId || null;
|
const draftIdAtStart = state.draftId || null;
|
||||||
const draftNameAtStart = state.draftName || 'Untitled';
|
const draftNameAtStart = state.draftName || 'Untitled';
|
||||||
@@ -4675,10 +4618,13 @@ async function _persistDraft() {
|
|||||||
};
|
};
|
||||||
const serializedBody = JSON.stringify(body);
|
const serializedBody = JSON.stringify(body);
|
||||||
if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) {
|
if (serializedBody.length > _EDITOR_PROJECT_MAX_BYTES) {
|
||||||
_setDraftStatus('Save paused', 'error');
|
const message = 'Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.';
|
||||||
const err = new Error('Draft autosave paused: this layered document exceeds the 256 MB safety limit. Export or flatten a copy before closing.');
|
if (isCurrentSession()) {
|
||||||
err.status = 413;
|
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 9000);
|
||||||
throw err;
|
state.persistErrorMessage = message;
|
||||||
|
_setDraftStatus('Save paused', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const responseError = async (res, method) => {
|
const responseError = async (res, method) => {
|
||||||
let detail = '';
|
let detail = '';
|
||||||
@@ -4721,51 +4667,29 @@ async function _persistDraft() {
|
|||||||
};
|
};
|
||||||
const savePromise = doSave();
|
const savePromise = doSave();
|
||||||
state.persistInFlight = savePromise
|
state.persistInFlight = savePromise
|
||||||
|
.catch((e) => {
|
||||||
|
console.warn('[ge] draft save failed', e);
|
||||||
|
if (!isCurrentSession()) return;
|
||||||
|
const message = e?.status === 413
|
||||||
|
? 'Draft autosave paused: the document exceeds the configured storage limit.'
|
||||||
|
: `Draft autosave failed: ${e?.message || 'server error'}`;
|
||||||
|
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, 7000);
|
||||||
|
state.persistErrorMessage = message;
|
||||||
|
_setDraftStatus('Save failed', 'error');
|
||||||
|
})
|
||||||
.then((out) => {
|
.then((out) => {
|
||||||
|
if (isCurrentSession()) state.persistInFlight = null;
|
||||||
if (!isCurrentSession()) return;
|
if (!isCurrentSession()) return;
|
||||||
state.savedRevision = Math.max(state.savedRevision || 0, currentRevision);
|
|
||||||
state.persistRetryCount = 0;
|
|
||||||
if (out && out.id) {
|
if (out && out.id) {
|
||||||
state.draftId = out.id;
|
state.draftId = out.id;
|
||||||
_writeActiveEditorSession();
|
_writeActiveEditorSession();
|
||||||
}
|
}
|
||||||
state.persistErrorMessage = null;
|
state.persistErrorMessage = null;
|
||||||
if ((state.documentRevision || 0) > state.savedRevision || state.persistDirty) {
|
if (!state.persistErrorMessage) _setDraftStatus('Saved', 'saved');
|
||||||
state.persistDirty = true;
|
|
||||||
_setDraftStatus('Unsaved', 'dirty');
|
|
||||||
} else {
|
|
||||||
_setDraftStatus('Saved', 'saved');
|
|
||||||
}
|
|
||||||
if (state.persistDirty) {
|
if (state.persistDirty) {
|
||||||
state.persistDirty = false;
|
state.persistDirty = false;
|
||||||
_schedulePersist();
|
_schedulePersist();
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.warn('[ge] draft save failed', e);
|
|
||||||
if (!isCurrentSession()) return;
|
|
||||||
state.persistDirty = true;
|
|
||||||
const isPaused = e?.status === 413;
|
|
||||||
const message = isPaused
|
|
||||||
? (e?.message || 'Draft autosave paused: the document exceeds the configured storage limit.')
|
|
||||||
: `Draft autosave failed: ${e?.message || 'server error'}`;
|
|
||||||
if (state.persistErrorMessage !== message && uiModule) uiModule.showToast(message, isPaused ? 9000 : 7000);
|
|
||||||
state.persistErrorMessage = message;
|
|
||||||
_setDraftStatus(isPaused ? 'Save paused' : 'Save failed', isPaused ? 'paused' : 'error');
|
|
||||||
if (!isPaused && state.editorOpen) {
|
|
||||||
state.persistRetryCount = Math.min((state.persistRetryCount || 0) + 1, 5);
|
|
||||||
const retryDelay = Math.min(1000 * Math.pow(2, state.persistRetryCount), 15000);
|
|
||||||
if (state.persistRetryTimer) clearTimeout(state.persistRetryTimer);
|
|
||||||
state.persistRetryTimer = setTimeout(() => {
|
|
||||||
state.persistRetryTimer = null;
|
|
||||||
if (state.editorOpen && (state.persistDirty || (state.documentRevision || 0) > (state.savedRevision || 0))) {
|
|
||||||
_persistDraft();
|
|
||||||
}
|
|
||||||
}, retryDelay);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (isCurrentSession()) state.persistInFlight = null;
|
|
||||||
});
|
});
|
||||||
return state.persistInFlight;
|
return state.persistInFlight;
|
||||||
}
|
}
|
||||||
@@ -5457,24 +5381,7 @@ async function _confirmRasterizeLayer(layer) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _lastCursorRect = null;
|
|
||||||
let _lastCursorRectTime = 0;
|
|
||||||
|
|
||||||
function _invalidateCoordsCaches() {
|
|
||||||
invalidateCanvasCoordsCache();
|
|
||||||
_lastCursorRect = null;
|
|
||||||
_lastCursorRectTime = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _beginDraw(e) {
|
function _beginDraw(e) {
|
||||||
if (state.mainCanvas) {
|
|
||||||
// Prime canvas rect and cursor rect once before stroke begins
|
|
||||||
// to avoid synchronous layout reads during pointermove.
|
|
||||||
_canvasCoords(e, state.mainCanvas);
|
|
||||||
const now = performance.now();
|
|
||||||
_lastCursorRect = state.mainCanvas.getBoundingClientRect();
|
|
||||||
_lastCursorRectTime = now;
|
|
||||||
}
|
|
||||||
// Move-tool auto-select (Photoshop parity): when the Auto-Select option is
|
// Move-tool auto-select (Photoshop parity): when the Auto-Select option is
|
||||||
// on — or Ctrl/Cmd is held for a one-off — pick the topmost visible,
|
// on — or Ctrl/Cmd is held for a one-off — pick the topmost visible,
|
||||||
// position-unlocked layer under the pointer before the drag. If the picked
|
// position-unlocked layer under the pointer before the drag. If the picked
|
||||||
@@ -5521,11 +5428,13 @@ function _beginDraw(e) {
|
|||||||
}
|
}
|
||||||
if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&
|
if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&
|
||||||
['text', 'shape', 'placed'].includes(layer?.kind)) {
|
['text', 'shape', 'placed'].includes(layer?.kind)) {
|
||||||
// Paint tools need pixels: prompt for confirmation before destructive rasterization.
|
// Paint tools need pixels: rasterize the text/shape/placed layer silently
|
||||||
// If user cancels, editable text/shape/placed layer is preserved intact.
|
// (it stays undoable) so the very first stroke or pour works, instead of
|
||||||
// If user accepts, layer is converted with an undo boundary so it can be reverted.
|
// bouncing the user through a rasterize dialog that can end up behind the
|
||||||
void _confirmRasterizeLayer(layer);
|
// canvas where it cannot be clicked. If rasterizing is not possible, fall
|
||||||
return;
|
// back to the prompt.
|
||||||
|
if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }
|
||||||
|
// fall through — `layer` is a pixel layer now, so the tool runs below.
|
||||||
}
|
}
|
||||||
// Transform-tool drag (handle grab or move-fallback) — handler in
|
// Transform-tool drag (handle grab or move-fallback) — handler in
|
||||||
// editor/tools/transform-drag.js.
|
// editor/tools/transform-drag.js.
|
||||||
@@ -5571,13 +5480,6 @@ function _beginDraw(e) {
|
|||||||
_strokeTool.tryBegin(e);
|
_strokeTool.tryBegin(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _cursorRaf = null;
|
|
||||||
let _pendingCursorEvent = null;
|
|
||||||
const _cursorOverlayTools = new Set([
|
|
||||||
'eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal',
|
|
||||||
'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'
|
|
||||||
]);
|
|
||||||
|
|
||||||
function _continueDraw(e) {
|
function _continueDraw(e) {
|
||||||
if (state.tool === 'pen') return _penTool.drag(e);
|
if (state.tool === 'pen') return _penTool.drag(e);
|
||||||
if (state.tool === 'path') return _pathTool.drag(e);
|
if (state.tool === 'path') return _pathTool.drag(e);
|
||||||
@@ -5594,40 +5496,10 @@ function _continueDraw(e) {
|
|||||||
// when it's actually over the canvas, otherwise hide it.
|
// when it's actually over the canvas, otherwise hide it.
|
||||||
const overCanvas = state.mainCanvas && e.target === state.mainCanvas;
|
const overCanvas = state.mainCanvas && e.target === state.mainCanvas;
|
||||||
if (state.tool === 'eyedropper' && overCanvas) _eyedropperTool.preview(e);
|
if (state.tool === 'eyedropper' && overCanvas) _eyedropperTool.preview(e);
|
||||||
if (_cursorOverlayTools.has(state.tool) && state.mainCanvas) {
|
if (['eraser', 'pixel', 'inpaint', 'lasso', 'brush', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify', 'quickselect', 'objectselect'].includes(state.tool) && state.mainCanvas) {
|
||||||
const syncCursor = () => {
|
if (overCanvas) _updateBrushCursor(e);
|
||||||
if (!overCanvas) {
|
else if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||||
if (state.cursorEl) state.cursorEl.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (state.drawing) {
|
|
||||||
_pendingCursorEvent = e;
|
|
||||||
if (!_cursorRaf) {
|
|
||||||
_cursorRaf = requestAnimationFrame(() => {
|
|
||||||
_cursorRaf = null;
|
|
||||||
if (_pendingCursorEvent) {
|
|
||||||
_updateBrushCursor(_pendingCursorEvent);
|
|
||||||
_pendingCursorEvent = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_updateBrushCursor(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Mid-stroke, move the cursor only after the stroke has sampled the canvas
|
|
||||||
// rect: a style write first forces a synchronous style + layout pass of the
|
|
||||||
// whole editor on every pointer move, which made long strokes lag.
|
|
||||||
if (state.drawing) {
|
|
||||||
try { return _continueDrawTools(e); } finally { syncCursor(); }
|
|
||||||
}
|
|
||||||
syncCursor();
|
|
||||||
}
|
}
|
||||||
return _continueDrawTools(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _continueDrawTools(e) {
|
|
||||||
if (state.drawing) return _strokeTool.tryContinue(e);
|
|
||||||
// Transform-tool hover-cursor + handle drag — handler in
|
// Transform-tool hover-cursor + handle drag — handler in
|
||||||
// editor/tools/transform-drag.js. Returns true when the drag is
|
// editor/tools/transform-drag.js. Returns true when the drag is
|
||||||
// consuming the event (rotation / resize); the hover-cursor pass
|
// consuming the event (rotation / resize); the hover-cursor pass
|
||||||
@@ -5642,13 +5514,17 @@ function _continueDrawTools(e) {
|
|||||||
if (state.tool === 'quickselect') return _quickSelectTool.drag(e);
|
if (state.tool === 'quickselect') return _quickSelectTool.drag(e);
|
||||||
if (state.tool === 'objectselect') return _objectSelectTool.drag(e);
|
if (state.tool === 'objectselect') return _objectSelectTool.drag(e);
|
||||||
if (state.tool === 'patch') return _patchTool.drag(e);
|
if (state.tool === 'patch') return _patchTool.drag(e);
|
||||||
if (state.moving) return _continueMove(e);
|
if (!state.drawing) {
|
||||||
if (state.cropping || state.cropMoving) return _continueCrop(e);
|
if (state.moving) return _continueMove(e);
|
||||||
|
if (state.cropping || state.cropMoving) return _continueCrop(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// In-progress stroke (brush / eraser / inpaint / clone) — handler in
|
||||||
|
// editor/tools/stroke.js.
|
||||||
|
_strokeTool.tryContinue(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _endDraw(e) {
|
function _endDraw(e) {
|
||||||
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
|
|
||||||
if (_pendingCursorEvent) { _updateBrushCursor(_pendingCursorEvent); _pendingCursorEvent = null; }
|
|
||||||
if (_cursorLoupeVisible) _setCursorLoupe(false);
|
if (_cursorLoupeVisible) _setCursorLoupe(false);
|
||||||
if (state.tool === 'pen') return _penTool.end();
|
if (state.tool === 'pen') return _penTool.end();
|
||||||
if (state.tool === 'path') return _pathTool.end(e);
|
if (state.tool === 'path') return _pathTool.end(e);
|
||||||
@@ -5667,7 +5543,6 @@ function _endDraw(e) {
|
|||||||
// Stroke end (brush / eraser / inpaint / clone) — handler in
|
// Stroke end (brush / eraser / inpaint / clone) — handler in
|
||||||
// editor/tools/stroke.js.
|
// editor/tools/stroke.js.
|
||||||
_strokeTool.tryEnd(e);
|
_strokeTool.tryEnd(e);
|
||||||
_invalidateCoordsCaches();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating popup that appears after an inpaint stroke so the user can
|
// Floating popup that appears after an inpaint stroke so the user can
|
||||||
@@ -5704,33 +5579,19 @@ const _endStroke = _strokePipeline.endStroke;
|
|||||||
|
|
||||||
// ── Brush cursor overlay ──
|
// ── Brush cursor overlay ──
|
||||||
|
|
||||||
|
|
||||||
function _updateBrushCursor(e) {
|
function _updateBrushCursor(e) {
|
||||||
if (!state.mainCanvas) return;
|
if (!state.mainCanvas) return;
|
||||||
const now = performance.now();
|
const rect = state.mainCanvas.getBoundingClientRect();
|
||||||
let rect;
|
|
||||||
if (_lastCursorRect && (state.drawing || (now - _lastCursorRectTime < 32))) {
|
|
||||||
rect = _lastCursorRect;
|
|
||||||
} else {
|
|
||||||
rect = state.mainCanvas.getBoundingClientRect();
|
|
||||||
_lastCursorRect = rect;
|
|
||||||
_lastCursorRectTime = now;
|
|
||||||
}
|
|
||||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
|
|
||||||
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
|
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
|
||||||
|
|
||||||
if (!state.cursorEl) {
|
if (!state.cursorEl) {
|
||||||
let layer = document.getElementById('ge-cursor-layer');
|
|
||||||
if (!layer) {
|
|
||||||
layer = document.createElement('div');
|
|
||||||
layer.id = 'ge-cursor-layer';
|
|
||||||
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;contain:strict;z-index:2147483647;';
|
|
||||||
document.body.appendChild(layer);
|
|
||||||
}
|
|
||||||
state.cursorEl = document.createElement('div');
|
state.cursorEl = document.createElement('div');
|
||||||
state.cursorEl.className = 'ge-brush-cursor';
|
state.cursorEl.className = 'ge-brush-cursor';
|
||||||
layer.appendChild(state.cursorEl);
|
document.body.appendChild(state.cursorEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lasso uses the feather radius (or a sensible default) so the circle
|
// Lasso uses the feather radius (or a sensible default) so the circle
|
||||||
@@ -5752,23 +5613,14 @@ function _updateBrushCursor(e) {
|
|||||||
// or responsive layout changes the canvas independently of nominal zoom.
|
// or responsive layout changes the canvas independently of nominal zoom.
|
||||||
const diameterX = basePx * (rect.width / Math.max(1, state.mainCanvas.width));
|
const diameterX = basePx * (rect.width / Math.max(1, state.mainCanvas.width));
|
||||||
const diameterY = basePx * (rect.height / Math.max(1, state.mainCanvas.height));
|
const diameterY = basePx * (rect.height / Math.max(1, state.mainCanvas.height));
|
||||||
if (state._lastCursorDiameterX !== diameterX || state._lastCursorDiameterY !== diameterY) {
|
state.cursorEl.style.width = diameterX + 'px';
|
||||||
state._lastCursorDiameterX = diameterX;
|
state.cursorEl.style.height = diameterY + 'px';
|
||||||
state._lastCursorDiameterY = diameterY;
|
|
||||||
state.cursorEl.style.width = diameterX + 'px';
|
|
||||||
state.cursorEl.style.height = diameterY + 'px';
|
|
||||||
}
|
|
||||||
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
|
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
|
||||||
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
state.cursorEl.style.left = (clientX - diameterX / 2) + 'px';
|
||||||
if (state.cursorEl.style.display !== 'block') {
|
state.cursorEl.style.top = (clientY - diameterY / 2) + 'px';
|
||||||
state.cursorEl.style.display = 'block';
|
state.cursorEl.style.display = 'block';
|
||||||
}
|
state.cursorEl.dataset.clientX = String(clientX);
|
||||||
state.cursorClientX = clientX;
|
state.cursorEl.dataset.clientY = String(clientY);
|
||||||
state.cursorClientY = clientY;
|
|
||||||
if (!state.drawing) {
|
|
||||||
state.cursorEl.dataset.clientX = String(clientX);
|
|
||||||
state.cursorEl.dataset.clientY = String(clientY);
|
|
||||||
}
|
|
||||||
if (state.tool === 'inpaint') {
|
if (state.tool === 'inpaint') {
|
||||||
// Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also
|
// Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also
|
||||||
// flips the cursor so the user sees the effective mode before they
|
// flips the cursor so the user sees the effective mode before they
|
||||||
@@ -5789,13 +5641,9 @@ function _updateBrushCursor(e) {
|
|||||||
state.cursorEl.style.background = 'rgba(0,0,0,0.15)';
|
state.cursorEl.style.background = 'rgba(0,0,0,0.15)';
|
||||||
state.cursorEl.style.borderStyle = 'solid';
|
state.cursorEl.style.borderStyle = 'solid';
|
||||||
} else {
|
} else {
|
||||||
const borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
|
state.cursorEl.style.borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
|
||||||
if (state._lastCursorBorderColor !== borderColor) {
|
state.cursorEl.style.background = 'transparent';
|
||||||
state._lastCursorBorderColor = borderColor;
|
state.cursorEl.style.borderStyle = 'solid';
|
||||||
state.cursorEl.style.borderColor = borderColor;
|
|
||||||
state.cursorEl.style.background = 'transparent';
|
|
||||||
state.cursorEl.style.borderStyle = 'solid';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9628,7 +9476,6 @@ function _getFitZoom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _applyZoom({ resetPan = false } = {}) {
|
function _applyZoom({ resetPan = false } = {}) {
|
||||||
_invalidateCoordsCaches();
|
|
||||||
if (!state.mainCanvas) return;
|
if (!state.mainCanvas) return;
|
||||||
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
|
||||||
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
|
||||||
@@ -9638,9 +9485,6 @@ function _applyZoom({ resetPan = false } = {}) {
|
|||||||
const area = state.container && state.container.querySelector('.ge-canvas-area');
|
const area = state.container && state.container.querySelector('.ge-canvas-area');
|
||||||
if (resetPan && area && area._resetPan) area._resetPan();
|
if (resetPan && area && area._resetPan) area._resetPan();
|
||||||
_syncSelectionOverlay();
|
_syncSelectionOverlay();
|
||||||
// The transform overlay is sized and placed for the zoom it was drawn at;
|
|
||||||
// a refit (e.g. the phone Layers sheet collapsing) must redraw it.
|
|
||||||
if (state.transformActive) _drawTransformHandles();
|
|
||||||
_precisionGuides?.redrawRulers();
|
_precisionGuides?.redrawRulers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10493,10 +10337,7 @@ function _buildEditor(container) {
|
|||||||
if (e) _eyedropperTool.preview(e);
|
if (e) _eyedropperTool.preview(e);
|
||||||
},
|
},
|
||||||
syncZoomControls: () => _syncZoomControls(),
|
syncZoomControls: () => _syncZoomControls(),
|
||||||
onViewportChange: () => {
|
onViewportChange: () => _precisionGuides?.redrawRulers(),
|
||||||
_precisionGuides?.redrawRulers();
|
|
||||||
if (state.transformActive) _drawTransformHandles();
|
|
||||||
},
|
|
||||||
onTwoFingerTap: () => undo(),
|
onTwoFingerTap: () => undo(),
|
||||||
onThreeFingerTap: () => redo(),
|
onThreeFingerTap: () => redo(),
|
||||||
});
|
});
|
||||||
@@ -10564,7 +10405,7 @@ function _buildEditor(container) {
|
|||||||
composite();
|
composite();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { rightPanel, controls, mobilePanels, layerPanel, dock } = buildRightPanel({
|
const { rightPanel, controls, layerPanel, dock } = buildRightPanel({
|
||||||
controlsHTML: _controlsHTML,
|
controlsHTML: _controlsHTML,
|
||||||
layerPanelHTML: _layerPanelHTML,
|
layerPanelHTML: _layerPanelHTML,
|
||||||
extraPanels: [
|
extraPanels: [
|
||||||
@@ -10593,7 +10434,6 @@ function _buildEditor(container) {
|
|||||||
});
|
});
|
||||||
editorBody.appendChild(rightPanel);
|
editorBody.appendChild(rightPanel);
|
||||||
panelVisibility.apply();
|
panelVisibility.apply();
|
||||||
panelVisibility.subscribe(_invalidateCoordsCaches);
|
|
||||||
window.gePanelVisibility = panelVisibility;
|
window.gePanelVisibility = panelVisibility;
|
||||||
// Remember the workspace/window layout the user last chose and restore it on
|
// Remember the workspace/window layout the user last chose and restore it on
|
||||||
// open, so the editor comes back the way they left it. A fresh session has no
|
// open, so the editor comes back the way they left it. A fresh session has no
|
||||||
@@ -10662,72 +10502,16 @@ function _buildEditor(container) {
|
|||||||
_preflight?.maybeOpen();
|
_preflight?.maybeOpen();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try { _initControlKit(container); } catch (_) {}
|
try { _initControlKit(container); } catch (_) {}
|
||||||
let _lastLayoutDesktop = null;
|
// Tool options live in the options bar (Photoshop), not the dock. On
|
||||||
const _syncResponsiveLayout = (force = false) => {
|
// desktop the controls panel is moved under the contextual strip so the
|
||||||
_invalidateCoordsCaches();
|
// dock is reserved for Color / Adjustments / Layers; on mobile
|
||||||
const isDesktop = window.innerWidth > 700;
|
// right-panel.js already re-parents it into a bottom sheet.
|
||||||
if (!force && isDesktop === _lastLayoutDesktop) return;
|
if (window.innerWidth > 700 && controls) {
|
||||||
_lastLayoutDesktop = isDesktop;
|
optionsBar.appendChild(controls);
|
||||||
|
controls.classList.add('ge-controls--in-options');
|
||||||
const footer = container.querySelector('.ge-editor-footer');
|
// The "..." overflow affordance is shown/hidden per tool inside
|
||||||
const hdr = container.querySelector('.ge-layers-header');
|
// renderOptionsBar (it only reveals the secondary brush rows).
|
||||||
const fh = container.querySelector('.ge-footer-history');
|
}
|
||||||
const fz = container.querySelector('.ge-footer-zoom');
|
|
||||||
const setupBtn = document.getElementById('ge-setup-btn');
|
|
||||||
const aiBtn = document.getElementById('ge-ai-settings-btn');
|
|
||||||
const optionsBar = container.querySelector('.ge-options-bar');
|
|
||||||
|
|
||||||
if (isDesktop) {
|
|
||||||
// Reparent controls into optionsBar
|
|
||||||
if (controls && optionsBar && controls.parentElement !== optionsBar) {
|
|
||||||
optionsBar.appendChild(controls);
|
|
||||||
}
|
|
||||||
controls?.classList.add('ge-controls--in-options');
|
|
||||||
controls?.classList.remove('dismissed');
|
|
||||||
optionsBar?.classList.add('ge-options-bar--has-controls');
|
|
||||||
|
|
||||||
// Right panel dock mode
|
|
||||||
rightPanel?.classList.add('ge-right-panel--dock');
|
|
||||||
if (dock?.dock) dock.dock.style.display = '';
|
|
||||||
if (mobilePanels) mobilePanels.style.display = 'none';
|
|
||||||
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
|
|
||||||
if (panelsBtn) panelsBtn.style.display = 'none';
|
|
||||||
|
|
||||||
// Clear mobile footer docking on desktop
|
|
||||||
if (fh && footer && fh.parentElement !== footer) {
|
|
||||||
footer.insertBefore(fh, footer.firstChild);
|
|
||||||
}
|
|
||||||
_placeMobileFooter();
|
|
||||||
} else {
|
|
||||||
// Reparent controls into editor container (mobile sheet)
|
|
||||||
if (controls && controls.parentElement !== container) {
|
|
||||||
container.appendChild(controls);
|
|
||||||
}
|
|
||||||
controls?.classList.remove('ge-controls--in-options');
|
|
||||||
optionsBar?.classList.remove('ge-options-bar--has-controls');
|
|
||||||
|
|
||||||
// Right panel mobile sheet mode
|
|
||||||
rightPanel?.classList.remove('ge-right-panel--dock');
|
|
||||||
if (dock?.dock) dock.dock.style.display = 'none';
|
|
||||||
if (mobilePanels) mobilePanels.style.display = '';
|
|
||||||
const panelsBtn = layerPanel?.querySelector('.ge-layers-panels-btn');
|
|
||||||
if (panelsBtn) panelsBtn.style.display = '';
|
|
||||||
|
|
||||||
if (fh && hdr && fh.parentElement !== hdr) {
|
|
||||||
hdr.appendChild(fh);
|
|
||||||
}
|
|
||||||
// Dock footer flush to sheet on mobile
|
|
||||||
_placeMobileFooter();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_syncResponsiveLayout(true);
|
|
||||||
try {
|
|
||||||
const _mobileMql = window.matchMedia('(max-width: 700px)');
|
|
||||||
_mobileMql.addEventListener('change', () => _syncResponsiveLayout(true));
|
|
||||||
} catch (_) {}
|
|
||||||
window.addEventListener('resize', () => _syncResponsiveLayout(false), { passive: true });
|
|
||||||
|
|
||||||
// Crop options: aspect-ratio presets (Free / Original / 1:1 / 4:5 / 16:9).
|
// Crop options: aspect-ratio presets (Free / Original / 1:1 / 4:5 / 16:9).
|
||||||
// Choosing one locks new crop drags to the ratio and refits an existing
|
// Choosing one locks new crop drags to the ratio and refits an existing
|
||||||
// crop rect (centred, clamped to the canvas). Wired after the controls
|
// crop rect (centred, clamped to the canvas). Wired after the controls
|
||||||
@@ -12147,13 +11931,7 @@ function _buildEditor(container) {
|
|||||||
const flat = _aiEditSourceCanvas();
|
const flat = _aiEditSourceCanvas();
|
||||||
const image = flat.toDataURL('image/png').split(',')[1];
|
const image = flat.toDataURL('image/png').split(',')[1];
|
||||||
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||||
const qeModel = _getSelectedQuickEditModel();
|
_startAiProgress(requestId);
|
||||||
const endpointId = payload._endpoint || qeModel.endpoint || '';
|
|
||||||
if (endpointId) {
|
|
||||||
_startAiProgress(requestId, endpointId);
|
|
||||||
} else {
|
|
||||||
_startAiProgress(requestId);
|
|
||||||
}
|
|
||||||
const body = JSON.stringify({ image, request_id: requestId, ...payload });
|
const body = JSON.stringify({ image, request_id: requestId, ...payload });
|
||||||
// The image servers occasionally reject a request transiently (503 while a
|
// The image servers occasionally reject a request transiently (503 while a
|
||||||
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
||||||
@@ -13719,11 +13497,6 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
|||||||
state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled');
|
state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled');
|
||||||
state.editorSessionToken += 1;
|
state.editorSessionToken += 1;
|
||||||
state.editorOpen = true;
|
state.editorOpen = true;
|
||||||
state.documentRevision = 0;
|
|
||||||
state.persistingRevision = 0;
|
|
||||||
state.savedRevision = 0;
|
|
||||||
state.persistRetryCount = 0;
|
|
||||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
|
||||||
_writeActiveEditorSession();
|
_writeActiveEditorSession();
|
||||||
state.layers = [];
|
state.layers = [];
|
||||||
state.selectedLayerIds = [];
|
state.selectedLayerIds = [];
|
||||||
@@ -13774,8 +13547,6 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
|||||||
_stopSelectionAnimation();
|
_stopSelectionAnimation();
|
||||||
window.__galleryEditLive = true;
|
window.__galleryEditLive = true;
|
||||||
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
||||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
|
||||||
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
|
|
||||||
state.persistDirty = false;
|
state.persistDirty = false;
|
||||||
state.persistErrorMessage = null;
|
state.persistErrorMessage = null;
|
||||||
state.persistInFlight = null;
|
state.persistInFlight = null;
|
||||||
@@ -14022,8 +13793,6 @@ export function closeEditor(options = {}) {
|
|||||||
// the editor mid-stroke doesn't lose work. The call is fire-and-forget;
|
// the editor mid-stroke doesn't lose work. The call is fire-and-forget;
|
||||||
// the server commit lands shortly after the modal hides.
|
// the server commit lands shortly after the modal hides.
|
||||||
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
|
||||||
if (state.persistRetryTimer) { clearTimeout(state.persistRetryTimer); state.persistRetryTimer = null; }
|
|
||||||
if (state.pendingStatusTimer) { clearTimeout(state.pendingStatusTimer); state.pendingStatusTimer = null; }
|
|
||||||
if (state.layers.length) {
|
if (state.layers.length) {
|
||||||
try { _persistDraft(); } catch {}
|
try { _persistDraft(); } catch {}
|
||||||
}
|
}
|
||||||
@@ -14062,7 +13831,6 @@ export function closeEditor(options = {}) {
|
|||||||
try { document.removeEventListener('click', h); } catch {}
|
try { document.removeEventListener('click', h); } catch {}
|
||||||
}
|
}
|
||||||
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
||||||
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
|
|
||||||
// Tear down all floating popups + the dock so closing the editor
|
// Tear down all floating popups + the dock so closing the editor
|
||||||
// doesn't leave stale chips/panels behind on top of the gallery.
|
// doesn't leave stale chips/panels behind on top of the gallery.
|
||||||
try { _closeFxMenu(); } catch {}
|
try { _closeFxMenu(); } catch {}
|
||||||
|
|||||||
@@ -173,15 +173,6 @@ export function initSidebarLayout(Storage, opts) {
|
|||||||
syncRailSide();
|
syncRailSide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move the closed drawer to the other edge without transitioning its
|
|
||||||
// transform across the screen, so it then slides in from the edge it opens on.
|
|
||||||
function _setSidebarSideInstantly(sidebar, right) {
|
|
||||||
sidebar.style.setProperty('transition', 'none', 'important');
|
|
||||||
sidebar.classList.toggle('right-side', right);
|
|
||||||
void sidebar.offsetWidth;
|
|
||||||
sidebar.style.removeProperty('transition');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliberate "open the sidebar" used by the mobile swipe gesture (wired at
|
// Deliberate "open the sidebar" used by the mobile swipe gesture (wired at
|
||||||
// module scope). It MUST set _userToggledSidebar so the auto-collapse
|
// module scope). It MUST set _userToggledSidebar so the auto-collapse
|
||||||
// MutationObserver doesn't immediately re-hide it (the swipe was opening it,
|
// MutationObserver doesn't immediately re-hide it (the swipe was opening it,
|
||||||
@@ -201,7 +192,7 @@ export function initSidebarLayout(Storage, opts) {
|
|||||||
if (side === 'left' || side === 'right') {
|
if (side === 'left' || side === 'right') {
|
||||||
const wantRight = side === 'right';
|
const wantRight = side === 'right';
|
||||||
if (sidebar.classList.contains('right-side') !== wantRight) {
|
if (sidebar.classList.contains('right-side') !== wantRight) {
|
||||||
_setSidebarSideInstantly(sidebar, wantRight);
|
sidebar.classList.toggle('right-side', wantRight);
|
||||||
// Swipe-selected sides are temporary on compact/mobile layouts. Do
|
// Swipe-selected sides are temporary on compact/mobile layouts. Do
|
||||||
// not overwrite the user's desktop sidebar preference.
|
// not overwrite the user's desktop sidebar preference.
|
||||||
if (window.innerWidth >= 768) {
|
if (window.innerWidth >= 768) {
|
||||||
@@ -239,7 +230,7 @@ export function initSidebarLayout(Storage, opts) {
|
|||||||
// Mobile: the hamburger always opens the sidebar from the RIGHT.
|
// Mobile: the hamburger always opens the sidebar from the RIGHT.
|
||||||
// (Not persisted — keeps the desktop side preference untouched.)
|
// (Not persisted — keeps the desktop side preference untouched.)
|
||||||
if (!sidebar.classList.contains('right-side')) {
|
if (!sidebar.classList.contains('right-side')) {
|
||||||
_setSidebarSideInstantly(sidebar, true);
|
sidebar.classList.add('right-side');
|
||||||
if (documentModule && documentModule.swapSide) { try { documentModule.swapSide(); } catch (_) {} }
|
if (documentModule && documentModule.swapSide) { try { documentModule.swapSide(); } catch (_) {} }
|
||||||
}
|
}
|
||||||
// Opening sidebar — blur keyboard first, then open after layout settles
|
// Opening sidebar — blur keyboard first, then open after layout settles
|
||||||
@@ -324,20 +315,14 @@ export function initSidebarLayout(Storage, opts) {
|
|||||||
_wasAutoCollapsed = true;
|
_wasAutoCollapsed = true;
|
||||||
syncRailSide();
|
syncRailSide();
|
||||||
} else if (window.innerWidth >= AUTO_COLLAPSE_WIDTH && isHidden && _wasAutoCollapsed) {
|
} else if (window.innerWidth >= AUTO_COLLAPSE_WIDTH && isHidden && _wasAutoCollapsed) {
|
||||||
// Only restore if chat won't be too narrow. Suppress the width
|
// Only restore if chat won't be too narrow
|
||||||
// transition and sync the mode classes (ody-sidebar-mini also forces
|
|
||||||
// width 0) so the reflow measures the sidebar at its full width.
|
|
||||||
sidebar.style.transition = 'none';
|
|
||||||
sidebar.classList.remove('hidden');
|
sidebar.classList.remove('hidden');
|
||||||
syncRailSide();
|
|
||||||
void document.body.offsetWidth; // reflow
|
void document.body.offsetWidth; // reflow
|
||||||
if (chatContainer && chatContainer.offsetWidth < MIN_CHAT_WIDTH) {
|
if (chatContainer && chatContainer.offsetWidth < MIN_CHAT_WIDTH) {
|
||||||
sidebar.classList.add('hidden');
|
sidebar.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
_wasAutoCollapsed = false;
|
_wasAutoCollapsed = false;
|
||||||
}
|
}
|
||||||
void document.body.offsetWidth;
|
|
||||||
sidebar.style.transition = '';
|
|
||||||
syncRailSide();
|
syncRailSide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
|||||||
let degraded = !ENABLED; // true once we fall back to full re-render
|
let degraded = !ENABLED; // true once we fall back to full re-render
|
||||||
let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render
|
let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render
|
||||||
let pendingTail = ''; // most recent tail text awaiting a rAF re-render
|
let pendingTail = ''; // most recent tail text awaiting a rAF re-render
|
||||||
let tailGeneration = 0;
|
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
contentEl.textContent = '';
|
contentEl.textContent = '';
|
||||||
@@ -89,21 +88,13 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
|||||||
function scheduleTailRender(tailText) {
|
function scheduleTailRender(tailText) {
|
||||||
pendingTail = tailText;
|
pendingTail = tailText;
|
||||||
if (tailRaf !== null) return;
|
if (tailRaf !== null) return;
|
||||||
const generation = tailGeneration;
|
|
||||||
tailRaf = requestAnimationFrame(() => {
|
tailRaf = requestAnimationFrame(() => {
|
||||||
if (generation !== tailGeneration) return;
|
|
||||||
tailRaf = null;
|
tailRaf = null;
|
||||||
// The container may have been rewritten out from under us (chat.js writes
|
// The container may have been rewritten out from under us (chat.js writes
|
||||||
// contentEl.innerHTML for thinking indicators and tool blocks). Skip and
|
// contentEl.innerHTML for thinking indicators and tool blocks). Skip and
|
||||||
// let the next update() self-heal rather than appending stale tail nodes.
|
// let the next update() self-heal rather than appending stale tail nodes.
|
||||||
if (tailMarker && tailMarker.parentNode === contentEl) {
|
if (tailMarker && tailMarker.parentNode === contentEl) {
|
||||||
try {
|
renderTail(pendingTail);
|
||||||
renderTail(pendingTail);
|
|
||||||
} catch (err) {
|
|
||||||
degraded = true;
|
|
||||||
console.error('streamingRenderer: falling back to full render in rAF', err);
|
|
||||||
safeFullRender(lastText);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -166,41 +157,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
|||||||
highlight(contentEl);
|
highlight(contentEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeFullRender(fullText) {
|
|
||||||
try {
|
|
||||||
fullRender(fullText);
|
|
||||||
} catch (err) {
|
|
||||||
// The source survives even when the parser/highlighter is unavailable.
|
|
||||||
// textContent also ensures that untrusted message text stays inert.
|
|
||||||
console.error('streamingRenderer: preserving source as plain text', err);
|
|
||||||
contentEl.textContent = fullText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelPendingTail() {
|
|
||||||
tailGeneration++;
|
|
||||||
if (tailRaf !== null) {
|
|
||||||
cancelAnimationFrame(tailRaf);
|
|
||||||
tailRaf = null;
|
|
||||||
}
|
|
||||||
pendingTail = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDom() {
|
|
||||||
cancelPendingTail();
|
|
||||||
started = false;
|
|
||||||
tailMarker = null;
|
|
||||||
committedLen = 0;
|
|
||||||
tailShownLen = 0;
|
|
||||||
appendMode = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
resetDom();
|
|
||||||
lastText = '';
|
|
||||||
degraded = !ENABLED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the latest full source text.
|
// Render the latest full source text.
|
||||||
//
|
//
|
||||||
// PRECONDITION: callers must pass append-only text — each call's `fullText` must
|
// PRECONDITION: callers must pass append-only text — each call's `fullText` must
|
||||||
@@ -212,8 +168,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
|||||||
function update(fullText) {
|
function update(fullText) {
|
||||||
lastText = fullText;
|
lastText = fullText;
|
||||||
if (degraded) {
|
if (degraded) {
|
||||||
cancelPendingTail();
|
fullRender(fullText);
|
||||||
safeFullRender(fullText);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -223,34 +178,24 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
|||||||
// container. Rebuild from scratch so we never append onto foreign content or
|
// container. Rebuild from scratch so we never append onto foreign content or
|
||||||
// touch a detached marker.
|
// touch a detached marker.
|
||||||
if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) {
|
if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) {
|
||||||
resetDom();
|
started = false;
|
||||||
|
committedLen = 0;
|
||||||
|
tailShownLen = 0;
|
||||||
|
appendMode = null;
|
||||||
}
|
}
|
||||||
if (!started) start();
|
if (!started) start();
|
||||||
const next = splitFinalized(fullText, render, committedLen);
|
const next = splitFinalized(fullText, render, committedLen);
|
||||||
if (next > committedLen) {
|
if (next > committedLen) {
|
||||||
cancelPendingTail();
|
|
||||||
clearTail();
|
|
||||||
freeze(fullText.slice(committedLen, next));
|
freeze(fullText.slice(committedLen, next));
|
||||||
committedLen = next;
|
committedLen = next;
|
||||||
appendMode = null; // whatever was streaming is now frozen
|
appendMode = null; // whatever was streaming is now frozen
|
||||||
tailShownLen = 0;
|
tailShownLen = 0;
|
||||||
}
|
}
|
||||||
const tail = fullText.slice(committedLen);
|
renderTail(fullText.slice(committedLen));
|
||||||
if (!tail) {
|
|
||||||
cancelPendingTail();
|
|
||||||
clearTail();
|
|
||||||
tailShownLen = 0;
|
|
||||||
} else if (tailShownLen === 0) {
|
|
||||||
cancelPendingTail();
|
|
||||||
renderTail(tail);
|
|
||||||
} else {
|
|
||||||
scheduleTailRender(tail);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cancelPendingTail();
|
|
||||||
degraded = true;
|
degraded = true;
|
||||||
console.error('streamingRenderer: falling back to full render', err);
|
console.error('streamingRenderer: falling back to full render', err);
|
||||||
safeFullRender(fullText);
|
fullRender(fullText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,12 +205,9 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
|||||||
// reasons and so doesn't call this, but it completes the renderer's lifecycle and
|
// reasons and so doesn't call this, but it completes the renderer's lifecycle and
|
||||||
// is exercised by the tests.
|
// is exercised by the tests.
|
||||||
function finalize() {
|
function finalize() {
|
||||||
cancelPendingTail();
|
if (degraded) return;
|
||||||
if (degraded) {
|
|
||||||
safeFullRender(lastText);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
if (tailRaf !== null) { cancelAnimationFrame(tailRaf); tailRaf = null; }
|
||||||
if (!started) start();
|
if (!started) start();
|
||||||
clearTail();
|
clearTail();
|
||||||
appendMode = null;
|
appendMode = null;
|
||||||
@@ -277,9 +219,9 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
degraded = true;
|
degraded = true;
|
||||||
console.error('streamingRenderer: falling back to full render', err);
|
console.error('streamingRenderer: falling back to full render', err);
|
||||||
safeFullRender(lastText);
|
fullRender(lastText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { update, finalize, reset };
|
return { update, finalize };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const TOOL_WINDOW_SELECTOR = 'body > .modal:not(#styled-confirm-overlay), body > .research-overlay, body > .notes-pane-backdrop';
|
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop';
|
||||||
|
|
||||||
export function topToolWindowZ(options = {}) {
|
export function topToolWindowZ(options = {}) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
+1
-3
@@ -8,7 +8,7 @@ import themeModule from './theme.js?v=20260911organsrain1';
|
|||||||
import * as Modals from './modalManager.js';
|
import * as Modals from './modalManager.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js';
|
import { registerMenuDismiss, dismissTopEscapeLayer, dismissOrRemove } from './escMenuStack.js';
|
||||||
import { nextToolWindowZ, topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
|
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
|
||||||
|
|
||||||
let toastEl = null;
|
let toastEl = null;
|
||||||
let autoScrollEnabled = true;
|
let autoScrollEnabled = true;
|
||||||
@@ -793,12 +793,10 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
|||||||
const _prevFocus = document.activeElement;
|
const _prevFocus = document.activeElement;
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
overlay.style.display = '';
|
overlay.style.display = '';
|
||||||
overlay.style.zIndex = String(topPortalZ() + 1000);
|
|
||||||
|
|
||||||
function cleanup(result) {
|
function cleanup(result) {
|
||||||
overlay.classList.add('hidden');
|
overlay.classList.add('hidden');
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
overlay.style.zIndex = '';
|
|
||||||
okBtn.removeEventListener('click', onOk);
|
okBtn.removeEventListener('click', onOk);
|
||||||
cancelBtn.removeEventListener('click', onCancel);
|
cancelBtn.removeEventListener('click', onCancel);
|
||||||
altBtn.removeEventListener('click', onAlt);
|
altBtn.removeEventListener('click', onAlt);
|
||||||
|
|||||||
+3
-23
@@ -7,7 +7,7 @@
|
|||||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||||
// - API / non-GET: never cached.
|
// - API / non-GET: never cached.
|
||||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||||
const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
|
const CACHE_NAME = 'odysseus-v1113-pr40-editor-precache';
|
||||||
|
|
||||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||||
// gives offline math fallback glyphs instead of proper typesetting.
|
// gives offline math fallback glyphs instead of proper typesetting.
|
||||||
@@ -74,8 +74,6 @@ const PRECACHE = [
|
|||||||
'/static/js/fileHandler.js?v=20260909mobileattachmentedit1',
|
'/static/js/fileHandler.js?v=20260909mobileattachmentedit1',
|
||||||
'/static/js/voiceRecorder.js',
|
'/static/js/voiceRecorder.js',
|
||||||
'/static/js/actionMenuOrder.js',
|
'/static/js/actionMenuOrder.js',
|
||||||
'/static/js/platform.js',
|
|
||||||
'/static/js/modelSort.js',
|
|
||||||
'/static/js/models.js',
|
'/static/js/models.js',
|
||||||
'/static/js/rag.js',
|
'/static/js/rag.js',
|
||||||
'/static/js/presets.js?v=20260908personaname1',
|
'/static/js/presets.js?v=20260908personaname1',
|
||||||
@@ -366,29 +364,11 @@ self.addEventListener('activate', (e) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Offline fallback for /static/ JS/CSS. /static is a public, unauthenticated
|
|
||||||
// mount whose bytes do not depend on the request, but CORS adds `Vary: Origin`
|
|
||||||
// and module imports send an Origin header that install-time fetches omit, so a
|
|
||||||
// strict match hides the precached copy. Ignore Vary only for entries in this
|
|
||||||
// worker's own cache whose sole Vary header is Origin.
|
|
||||||
async function cachedStaticFile(request) {
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
|
||||||
const exact = await cache.match(request);
|
|
||||||
if (exact) return exact;
|
|
||||||
const variesOnlyOnOrigin = response => (response.headers.get('Vary') || '')
|
|
||||||
.split(',').map(name => name.trim().toLowerCase()).filter(Boolean)
|
|
||||||
.every(name => name === 'origin');
|
|
||||||
const candidates = await cache.matchAll(request, { ignoreVary: true });
|
|
||||||
return candidates.find(variesOnlyOnOrigin) || Response.error();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (e) => {
|
self.addEventListener('fetch', (e) => {
|
||||||
const url = new URL(e.request.url);
|
const url = new URL(e.request.url);
|
||||||
|
|
||||||
// Never touch API calls or non-GET.
|
// Never touch API calls or non-GET.
|
||||||
if (url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;
|
if (url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;
|
||||||
// Only this origin's own files are cached; anything else goes to the network.
|
|
||||||
if (url.origin !== self.location.origin) return;
|
|
||||||
|
|
||||||
// HTML navigation: network-first app shell — but ONLY for the SPA root.
|
// HTML navigation: network-first app shell — but ONLY for the SPA root.
|
||||||
// Other navigations (e.g. a deep-linked /static/*.html page) must go to the
|
// Other navigations (e.g. a deep-linked /static/*.html page) must go to the
|
||||||
@@ -414,10 +394,10 @@ self.addEventListener('fetch', (e) => {
|
|||||||
fetch(e.request).then(res => {
|
fetch(e.request).then(res => {
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
const copy = res.clone();
|
const copy = res.clone();
|
||||||
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy)).catch(() => {});
|
caches.open(CACHE_NAME).then(cache => cache.put(e.request, copy));
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}).catch(() => cachedStaticFile(e.request))
|
}).catch(() => caches.match(e.request))
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,402 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
test('Chat Photo Editor Action Integrity and Turn Scoping', async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const serverResponses = new Map();
|
|
||||||
const recordedUploadRequests = [];
|
|
||||||
|
|
||||||
await page.route('http://photo-test.local/**', async (route) => {
|
|
||||||
const url = new URL(route.request().url());
|
|
||||||
const path = url.pathname;
|
|
||||||
|
|
||||||
if (path === '/') {
|
|
||||||
return route.fulfill({
|
|
||||||
contentType: 'text/html',
|
|
||||||
body: `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<div id="toast"></div>
|
|
||||||
<div id="chat-history"></div>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.startsWith('/api/upload/')) {
|
|
||||||
const id = path.replace('/api/upload/', '');
|
|
||||||
const method = route.request().method();
|
|
||||||
recordedUploadRequests.push({ id, method });
|
|
||||||
const status = serverResponses.get(id) || 200;
|
|
||||||
if (status === 200) {
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'image/png',
|
|
||||||
'Content-Length': '16',
|
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
},
|
|
||||||
body: method === 'HEAD' ? Buffer.alloc(0) : Buffer.from('fake-image-bytes'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
|
||||||
status,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ detail: 'Upload error or forbidden' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
|
|
||||||
return route.fulfill({ contentType: 'text/javascript', body: source });
|
|
||||||
} catch (err) {
|
|
||||||
return route.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto('http://photo-test.local/');
|
|
||||||
|
|
||||||
const results = await page.evaluate(async () => {
|
|
||||||
const box = document.getElementById('chat-history');
|
|
||||||
|
|
||||||
// Import chat.js to test hooks
|
|
||||||
await import('/static/js/chat.js');
|
|
||||||
const hooks = window.__chatPhotoEditorTestHooks;
|
|
||||||
if (!hooks) throw new Error('Test hooks not exposed on window.__chatPhotoEditorTestHooks');
|
|
||||||
|
|
||||||
const { _isAuthorizedImageUrl, _findOriginatingUserMessage, _attachedImageForTurn, renderPhotoEditorApplyButton } = hooks;
|
|
||||||
|
|
||||||
// --- Scenario 1: Authorization checks ---
|
|
||||||
const authValidUpload = _isAuthorizedImageUrl('/api/upload/img_123');
|
|
||||||
const authValidGallery = _isAuthorizedImageUrl('/api/gallery/img_123');
|
|
||||||
const authBlob = _isAuthorizedImageUrl('blob:http://photo-test.local/550e8400-e29b-41d4-a716-446655440000');
|
|
||||||
const authCrossOriginBlob = _isAuthorizedImageUrl('blob:https://evil.com/550e8400-e29b-41d4-a716-446655440000');
|
|
||||||
const authMalformedBlob = _isAuthorizedImageUrl('blob:javascript:alert(1)');
|
|
||||||
const pixel = document.createElement('canvas'); pixel.width = pixel.height = 1;
|
|
||||||
const authDataPng = _isAuthorizedImageUrl(pixel.toDataURL('image/png'));
|
|
||||||
const authDataJpeg = _isAuthorizedImageUrl(pixel.toDataURL('image/jpeg'));
|
|
||||||
const authDataWebp = _isAuthorizedImageUrl(pixel.toDataURL('image/webp'));
|
|
||||||
const authDataGif = _isAuthorizedImageUrl('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
|
||||||
const authDataSvg = _isAuthorizedImageUrl('data:image/svg+xml;base64,PHN2Zz4=');
|
|
||||||
const authDataHtml = _isAuthorizedImageUrl('data:text/html;base64,PGgxPlRlc3Q=');
|
|
||||||
const authDataMalformed = _isAuthorizedImageUrl('data:image/png;base64,invalid!@#$');
|
|
||||||
const authDataOversized = _isAuthorizedImageUrl('data:image/png;base64,' + 'A'.repeat(11 * 1024 * 1024));
|
|
||||||
const authMaliciousDomain = _isAuthorizedImageUrl('https://evil.com/api/upload/img_123');
|
|
||||||
const authSchemeRelative = _isAuthorizedImageUrl('//evil.com/api/upload/img_123');
|
|
||||||
const authJavascript = _isAuthorizedImageUrl('javascript:alert(1)');
|
|
||||||
const authDirectoryTraversal = _isAuthorizedImageUrl('/api/upload/../../../etc/passwd');
|
|
||||||
const authEncodedTraversal = _isAuthorizedImageUrl('/api/upload/%2e%2e%2f');
|
|
||||||
|
|
||||||
// --- Scenario 2: Multi-turn isolation ---
|
|
||||||
// Build Turn 1: User attaches img_turn1
|
|
||||||
const user1 = document.createElement('div');
|
|
||||||
user1.className = 'msg msg-user';
|
|
||||||
user1.innerHTML = '<div class="body">Turn 1 text</div><div class="attach-image-preview" data-file-id="img_turn1"><img src="/api/upload/img_turn1" /></div>';
|
|
||||||
box.appendChild(user1);
|
|
||||||
|
|
||||||
const ai1 = document.createElement('div');
|
|
||||||
ai1.className = 'msg msg-ai round-holder';
|
|
||||||
ai1.innerHTML = '<div class="body">Turn 1 AI reply</div>';
|
|
||||||
box.appendChild(ai1);
|
|
||||||
|
|
||||||
// Build Turn 2: User attaches NO photo (just text)
|
|
||||||
const user2 = document.createElement('div');
|
|
||||||
user2.className = 'msg msg-user';
|
|
||||||
user2.innerHTML = '<div class="body">Turn 2 text without photo</div>';
|
|
||||||
box.appendChild(user2);
|
|
||||||
|
|
||||||
const ai2 = document.createElement('div');
|
|
||||||
ai2.className = 'msg msg-ai round-holder';
|
|
||||||
ai2.innerHTML = '<div class="body">Turn 2 AI reply</div>';
|
|
||||||
box.appendChild(ai2);
|
|
||||||
|
|
||||||
// Build Turn 3: User attaches img_turn3
|
|
||||||
const user3 = document.createElement('div');
|
|
||||||
user3.className = 'msg msg-user';
|
|
||||||
user3.innerHTML = '<div class="body">Turn 3 text</div><div class="attach-card" data-file-id="img_turn3"><img src="/api/upload/img_turn3" /></div>';
|
|
||||||
box.appendChild(user3);
|
|
||||||
|
|
||||||
const ai3 = document.createElement('div');
|
|
||||||
ai3.className = 'msg msg-ai round-holder';
|
|
||||||
ai3.innerHTML = '<div class="body">Turn 3 AI reply</div>';
|
|
||||||
box.appendChild(ai3);
|
|
||||||
|
|
||||||
// Resolve images for each turn
|
|
||||||
const turn1Resolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', prompt: 'edit turn 1' });
|
|
||||||
const turn2Resolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', prompt: 'edit turn 2' });
|
|
||||||
const turn3Resolved = _attachedImageForTurn(ai3, { kind: 'open_and_apply', prompt: 'edit turn 3' });
|
|
||||||
|
|
||||||
// Action with explicit imageId should honor explicit id
|
|
||||||
const explicitActionResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999' });
|
|
||||||
const filenameResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999.png' });
|
|
||||||
const invalidExplicitResolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', imageUrl: 'data:image/png;base64,=' });
|
|
||||||
|
|
||||||
// Action on container with no user message above
|
|
||||||
const detachedAi = document.createElement('div');
|
|
||||||
detachedAi.className = 'msg msg-ai';
|
|
||||||
const detachedResolved = _attachedImageForTurn(detachedAi, { kind: 'open_and_apply' });
|
|
||||||
|
|
||||||
return {
|
|
||||||
authValidUpload,
|
|
||||||
authValidGallery,
|
|
||||||
authBlob,
|
|
||||||
authCrossOriginBlob,
|
|
||||||
authMalformedBlob,
|
|
||||||
authDataPng,
|
|
||||||
authDataJpeg,
|
|
||||||
authDataWebp,
|
|
||||||
authDataGif,
|
|
||||||
authDataSvg,
|
|
||||||
authDataHtml,
|
|
||||||
authDataMalformed,
|
|
||||||
authDataOversized,
|
|
||||||
authMaliciousDomain,
|
|
||||||
authSchemeRelative,
|
|
||||||
authJavascript,
|
|
||||||
authDirectoryTraversal,
|
|
||||||
authEncodedTraversal,
|
|
||||||
turn1Resolved,
|
|
||||||
turn2Resolved,
|
|
||||||
turn3Resolved,
|
|
||||||
explicitActionResolved,
|
|
||||||
filenameResolved,
|
|
||||||
invalidExplicitResolved,
|
|
||||||
detachedResolved,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify Scenario 1: Authorization
|
|
||||||
assert.equal(results.authValidUpload, true, 'Valid same-origin upload is authorized');
|
|
||||||
assert.equal(results.authValidGallery, true, 'Valid same-origin gallery is authorized');
|
|
||||||
assert.equal(results.authBlob, false, 'Same-origin alone does not authorize a blob URL');
|
|
||||||
assert.equal(results.authCrossOriginBlob, false, 'Cross-origin blob URL is rejected');
|
|
||||||
assert.equal(results.authMalformedBlob, false, 'Malformed blob URL is rejected');
|
|
||||||
assert.equal(results.authDataPng, true, 'PNG data URL is authorized');
|
|
||||||
assert.equal(results.authDataJpeg, true, 'JPEG data URL is authorized');
|
|
||||||
assert.equal(results.authDataWebp, true, 'WebP data URL is authorized');
|
|
||||||
assert.equal(results.authDataGif, true, 'GIF data URL is authorized');
|
|
||||||
assert.equal(results.authDataSvg, false, 'SVG data URL is rejected');
|
|
||||||
assert.equal(results.authDataHtml, false, 'HTML data URL is rejected');
|
|
||||||
assert.equal(results.authDataMalformed, false, 'Malformed data URL is rejected');
|
|
||||||
assert.equal(results.authDataOversized, false, 'Oversized data URL > 10MB is rejected');
|
|
||||||
assert.equal(results.authMaliciousDomain, false, 'External malicious domain rejected');
|
|
||||||
assert.equal(results.authSchemeRelative, false, 'Scheme-relative URL rejected');
|
|
||||||
assert.equal(results.authJavascript, false, 'Javascript scheme rejected');
|
|
||||||
assert.equal(results.authDirectoryTraversal, false, 'Directory traversal in path rejected');
|
|
||||||
assert.equal(results.authEncodedTraversal, false, 'Encoded directory traversal in path rejected');
|
|
||||||
|
|
||||||
// Verify Scenario 2: Multi-turn isolation
|
|
||||||
assert.equal(results.turn1Resolved, '/api/upload/img_turn1', 'Turn 1 AI correctly resolves Turn 1 user image');
|
|
||||||
assert.equal(results.turn2Resolved, null, 'Turn 2 AI resolves NULL because Turn 2 had no attached image');
|
|
||||||
assert.equal(results.turn3Resolved, '/api/upload/img_turn3', 'Turn 3 AI correctly resolves Turn 3 user image');
|
|
||||||
assert.equal(results.explicitActionResolved, '/api/upload/explicit_999', 'Explicit action imageId honored');
|
|
||||||
assert.equal(results.filenameResolved, '/api/upload/explicit_999.png', 'Canonical filenames keep their extensions');
|
|
||||||
assert.equal(results.invalidExplicitResolved, null, 'Invalid explicit data never falls back to a different image');
|
|
||||||
assert.equal(results.detachedResolved, null, 'Detached AI container without user message resolves to null');
|
|
||||||
|
|
||||||
// --- Scenario 3: Button click lifecycle (deleted upload, rapid click, open apply) ---
|
|
||||||
const clickResults = await page.evaluate(async () => {
|
|
||||||
const hooks = window.__chatPhotoEditorTestHooks;
|
|
||||||
const { renderPhotoEditorApplyButton } = hooks;
|
|
||||||
|
|
||||||
let editorCalls = [];
|
|
||||||
window.gePhotoEditorApply = async (opts) => {
|
|
||||||
editorCalls.push(opts);
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
};
|
|
||||||
|
|
||||||
let errorsShown = [];
|
|
||||||
const fakeUi = {
|
|
||||||
showError(msg) { errorsShown.push(msg); },
|
|
||||||
showToast(msg) {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Case A: Missing attachment on Turn 2
|
|
||||||
const ai2 = document.querySelectorAll('.msg-ai')[1];
|
|
||||||
renderPhotoEditorApplyButton(ai2, { kind: 'open_and_apply', prompt: 'test turn 2' });
|
|
||||||
const btn2 = ai2.querySelector('.chat-open-in-editor-btn');
|
|
||||||
btn2.click();
|
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
|
||||||
const btn2DisabledAfterMissing = btn2.disabled;
|
|
||||||
|
|
||||||
// Case B: Deleted upload (server returns 404)
|
|
||||||
const userDel = document.createElement('div');
|
|
||||||
userDel.className = 'msg msg-user';
|
|
||||||
userDel.innerHTML = '<div class="attach-image-preview" data-file-id="img_deleted"><img src="/api/upload/img_deleted" /></div>';
|
|
||||||
document.getElementById('chat-history').appendChild(userDel);
|
|
||||||
|
|
||||||
const aiDel = document.createElement('div');
|
|
||||||
aiDel.className = 'msg msg-ai round-holder';
|
|
||||||
document.getElementById('chat-history').appendChild(aiDel);
|
|
||||||
|
|
||||||
renderPhotoEditorApplyButton(aiDel, { kind: 'open_and_apply', prompt: 'test deleted' });
|
|
||||||
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasBtn2: !!btn2,
|
|
||||||
btn2DisabledAfterMissing,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(clickResults.hasBtn2, true);
|
|
||||||
assert.equal(clickResults.btn2DisabledAfterMissing, false, 'Button re-enabled after missing attachment');
|
|
||||||
|
|
||||||
// Set server status 404 for img_deleted
|
|
||||||
serverResponses.set('img_deleted', 404);
|
|
||||||
|
|
||||||
const deletedResults = await page.evaluate(async () => {
|
|
||||||
const aiDel = document.querySelectorAll('.msg-ai')[3];
|
|
||||||
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
|
|
||||||
btnDel.click();
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
return {
|
|
||||||
disabled: btnDel.disabled,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(deletedResults.disabled, false, 'Button re-enabled when server returns 404');
|
|
||||||
|
|
||||||
// Case C: Valid attachment on Turn 1 click launches editor
|
|
||||||
const validResults = await page.evaluate(async () => {
|
|
||||||
let editorCalls = [];
|
|
||||||
window.gePhotoEditorApply = async (opts) => {
|
|
||||||
editorCalls.push(opts);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ai1 = document.querySelectorAll('.msg-ai')[0];
|
|
||||||
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
|
||||||
renderPhotoEditorApplyButton(ai1, { kind: 'open_and_apply', prompt: 'make b&w' });
|
|
||||||
const btn1 = ai1.querySelector('.chat-open-in-editor-btn');
|
|
||||||
|
|
||||||
// Rapid clicks: trigger multiple synchronous clicks
|
|
||||||
btn1.click();
|
|
||||||
btn1.click();
|
|
||||||
btn1.click();
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
|
|
||||||
return {
|
|
||||||
editorCallsCount: editorCalls.length,
|
|
||||||
editorCall: editorCalls[0],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(validResults.editorCallsCount, 1, 'Rapid clicks deduplicated, editor called exactly once');
|
|
||||||
assert.equal(validResults.editorCall.imageUrl, '/api/upload/img_turn1', 'Editor opened with Turn 1 image');
|
|
||||||
assert.equal(validResults.editorCall.prompt, 'make b&w');
|
|
||||||
|
|
||||||
// Case D: Verify HEAD probe was used for verification
|
|
||||||
const headProbeObserved = recordedUploadRequests.some((r) => r.id === 'img_turn1' && r.method === 'HEAD');
|
|
||||||
assert.equal(headProbeObserved, true, 'Chat-to-editor preflight executed an authorized HEAD probe');
|
|
||||||
|
|
||||||
// Case E: Unauthorized upload returns 403, surfaces error and re-enables button
|
|
||||||
serverResponses.set('img_forbidden', 403);
|
|
||||||
const forbiddenResults = await page.evaluate(async () => {
|
|
||||||
const userForbidden = document.createElement('div');
|
|
||||||
userForbidden.className = 'msg msg-user';
|
|
||||||
userForbidden.innerHTML = '<div class="attach-image-preview" data-file-id="img_forbidden"><img src="/api/upload/img_forbidden" /></div>';
|
|
||||||
document.getElementById('chat-history').appendChild(userForbidden);
|
|
||||||
|
|
||||||
const aiForbidden = document.createElement('div');
|
|
||||||
aiForbidden.className = 'msg msg-ai round-holder';
|
|
||||||
document.getElementById('chat-history').appendChild(aiForbidden);
|
|
||||||
|
|
||||||
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
|
||||||
renderPhotoEditorApplyButton(aiForbidden, { kind: 'open_and_apply', prompt: 'forbidden test' });
|
|
||||||
const btnForb = aiForbidden.querySelector('.chat-open-in-editor-btn');
|
|
||||||
btnForb.click();
|
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
|
||||||
|
|
||||||
return {
|
|
||||||
disabled: btnForb.disabled,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(forbiddenResults.disabled, false, 'Button re-enabled when server returns 403');
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('editor actions validate raster bytes, dimensions, decode and reject all unverified blobs before launch', async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const apiRequests = [];
|
|
||||||
await page.route('http://raster.local/**', async route => {
|
|
||||||
const path = new URL(route.request().url()).pathname;
|
|
||||||
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="chat-history"></div>' });
|
|
||||||
if (path === '/api/gallery/owned') { apiRequests.push(path); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'owned', url: '/api/generated-image/owned.png' }) }); }
|
|
||||||
if (path === '/api/gallery/foreign') { apiRequests.push(path); return route.fulfill({ status: 404, body: '' }); }
|
|
||||||
if (path.startsWith('/api/upload/')) { apiRequests.push(path); return route.fulfill({ status: 200, headers: { 'Content-Type': 'image/png' }, body: '' }); }
|
|
||||||
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
|
|
||||||
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
|
|
||||||
catch { return route.fulfill({ status: 404, body: '' }); }
|
|
||||||
});
|
|
||||||
await page.goto('http://raster.local/');
|
|
||||||
const results = await page.evaluate(async () => {
|
|
||||||
await import('/static/js/chat.js');
|
|
||||||
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
|
||||||
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 2;
|
|
||||||
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
|
||||||
const png = canvas.toDataURL('image/png');
|
|
||||||
const bytes = Uint8Array.from(atob(png.split(',')[1]), ch => ch.charCodeAt(0));
|
|
||||||
const encode = bytes => { let text = ''; for (const byte of bytes) text += String.fromCharCode(byte); return 'data:image/png;base64,' + btoa(text); };
|
|
||||||
const headerDimensions = (w, h) => { const b = bytes.slice(); const v = new DataView(b.buffer); v.setUint32(16, w); v.setUint32(20, h); return encode(b); };
|
|
||||||
const invalidDecode = bytes.slice(0, 33); // valid signature/dimensions but no pixel data
|
|
||||||
const ownBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
|
|
||||||
const revoked = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); URL.revokeObjectURL(revoked);
|
|
||||||
// This action intentionally has no blob registration API. Even application-created
|
|
||||||
// object URLs must use their authorized server attachment or raster data source.
|
|
||||||
const cases = [
|
|
||||||
['upload', '/api/upload/owned', true], ['gallery', '/api/gallery/owned', true],
|
|
||||||
['png', png, true], ['jpeg', canvas.toDataURL('image/jpeg'), true], ['webp', canvas.toDataURL('image/webp'), true],
|
|
||||||
['gif', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', true],
|
|
||||||
['padding-one', 'data:image/png;base64,=', false], ['padding-length', 'data:image/png;base64,A', false],
|
|
||||||
['padding-middle', 'data:image/png;base64,AA=AA', false], ['padding-excess', 'data:image/png;base64,AAAA====', false], ['foreign-gallery', '/api/gallery/foreign', false],
|
|
||||||
['pad-bits', 'data:image/png;base64,AB==', false], ['bytes', 'data:image/png;base64,AAAA', false],
|
|
||||||
['mismatch', png.replace('image/png', 'image/jpeg'), false], ['truncated-image', encode(invalidDecode), false],
|
|
||||||
['svg', 'data:image/svg+xml;base64,PHN2Zy8+', false], ['html', 'data:text/html;base64,PGgxLz4=', false],
|
|
||||||
['svg-disguised', 'data:image/png;base64,PHN2Zy8+', false],
|
|
||||||
['foreign-blob', 'blob:https://foreign.invalid/deadbeef', false], ['arbitrary-blob', 'blob:http://raster.local/deadbeef', false],
|
|
||||||
['application-created-blob', ownBlob, false], ['revoked-blob', revoked, false],
|
|
||||||
['oversized-url', 'data:image/png;base64,' + 'A'.repeat(10 * 1024 * 1024), false],
|
|
||||||
['oversized-decoded-bytes', 'data:image/png;base64,' + 'A'.repeat(4 * Math.ceil((7 * 1024 * 1024 + 3) / 3)), false],
|
|
||||||
['dimension', headerDimensions(8193, 1), false], ['pixel-budget', headerDimensions(8192, 8192), false],
|
|
||||||
['scheme-relative', '//raster.local/api/upload/owned', false], ['encoded-traversal', '/api/upload/%2e%2e/owned', false],
|
|
||||||
];
|
|
||||||
const oldDecode = window.createImageBitmap; let decodes = 0;
|
|
||||||
window.createImageBitmap = (...args) => { decodes++; return oldDecode(...args); };
|
|
||||||
let launches = []; window.gePhotoEditorApply = async opts => launches.push(opts.imageUrl);
|
|
||||||
const output = [];
|
|
||||||
for (const [name, url, accepted] of cases) {
|
|
||||||
const host = document.createElement('div'); document.body.append(host);
|
|
||||||
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: url, prompt: 'synthetic edit' });
|
|
||||||
const btn = host.querySelector('button'); const before = launches.length; const decodeBefore = decodes;
|
|
||||||
btn.click(); btn.click(); btn.click();
|
|
||||||
while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5));
|
|
||||||
output.push({ name, accepted, launches: launches.length - before, decodes: decodes - decodeBefore }); host.remove();
|
|
||||||
}
|
|
||||||
// Cache reuse across repeated activation of the same complete inline image.
|
|
||||||
const host = document.createElement('div'); document.body.append(host);
|
|
||||||
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: png, prompt: 'repeat' });
|
|
||||||
const btn = host.querySelector('button'); const before = decodes;
|
|
||||||
for (let i = 0; i < 3; i++) { btn.click(); while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5)); }
|
|
||||||
const cachedDecodes = decodes - before;
|
|
||||||
URL.revokeObjectURL(ownBlob);
|
|
||||||
return { cases: output, cachedDecodes, launches };
|
|
||||||
});
|
|
||||||
assert.deepEqual(results.cases.filter(item => item.launches !== (item.accepted ? 1 : 0)).map(item => item.name), [], 'every raster authorization case must match');
|
|
||||||
for (const name of ['dimension', 'pixel-budget', 'oversized-url', 'oversized-decoded-bytes', 'bytes', 'mismatch']) {
|
|
||||||
assert.equal(results.cases.find(item => item.name === name).decodes, 0, name + ' rejected before browser allocation');
|
|
||||||
}
|
|
||||||
assert.equal(results.cases.find(item => item.name === 'truncated-image').decodes, 1, 'actual decoder rejects incomplete image');
|
|
||||||
assert.equal(results.cachedDecodes, 1, 'same image only decoded once for repeated activation');
|
|
||||||
assert.deepEqual(apiRequests, ['/api/upload/owned', '/api/gallery/owned', '/api/gallery/foreign']);
|
|
||||||
assert.ok(results.launches.includes('http://raster.local/api/generated-image/owned.png'));
|
|
||||||
} finally { await browser.close(); }
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"digest": "67c4508478daa8e0",
|
"digest": "f7e60e4a567c46dc",
|
||||||
"elements": {
|
"elements": {
|
||||||
"app-shell": {
|
"app-shell": {
|
||||||
"app-loader": "f04ad5bf6312d53f",
|
"app-loader": "f04ad5bf6312d53f",
|
||||||
"attach-strip": "a6474951e2481214",
|
"attach-strip": "a6474951e2481214",
|
||||||
"body": "4a4b8a158ea6b360",
|
"body": "4956f449e6a30e93",
|
||||||
"chat-container": "b00a7c6be4e107b1",
|
"chat-container": "b00a7c6be4e107b1",
|
||||||
"chat-context-pill": "dd905b87245a9a71",
|
"chat-context-pill": "dd905b87245a9a71",
|
||||||
"chat-history": "e65bdc51f4d1675f",
|
"chat-history": "e65bdc51f4d1675f",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"export-dropdown-item": "d90f6f513d06a6f7",
|
"export-dropdown-item": "d90f6f513d06a6f7",
|
||||||
"export-dropdown-menu": "0793e06a6aae6a7a",
|
"export-dropdown-menu": "0793e06a6aae6a7a",
|
||||||
"hamburger-btn": "34714f6eef3734d8",
|
"hamburger-btn": "34714f6eef3734d8",
|
||||||
"icon-rail": "c800b4ef07f5f1f0",
|
"icon-rail": "e3f72205260a4a5f",
|
||||||
"icon-rail-btn": "54ffb10e056ebf44",
|
"icon-rail-btn": "54ffb10e056ebf44",
|
||||||
"incognito-btn": "ee42bd3f6a32576b",
|
"incognito-btn": "ee42bd3f6a32576b",
|
||||||
"input-icon-btn": "3d1c773acd84a825",
|
"input-icon-btn": "3d1c773acd84a825",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"rail-new-chat": "f8f1c2f6be426ab9",
|
"rail-new-chat": "f8f1c2f6be426ab9",
|
||||||
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
|
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
|
||||||
"rename-session-modal": "9345624780009d14",
|
"rename-session-modal": "9345624780009d14",
|
||||||
"root": "a6694a5a5dbfbe44",
|
"root": "a9f7ace2c1cefb24",
|
||||||
"save-custom-preset": "443da70ef5fe9b88",
|
"save-custom-preset": "443da70ef5fe9b88",
|
||||||
"scroll-bottom-btn": "24602d72e2949991",
|
"scroll-bottom-btn": "24602d72e2949991",
|
||||||
"search-input": "53b94f3e5fdb384d",
|
"search-input": "53b94f3e5fdb384d",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"settings-row": "de568724268cec6e",
|
"settings-row": "de568724268cec6e",
|
||||||
"settings-select": "a96e8430b0ec6668",
|
"settings-select": "a96e8430b0ec6668",
|
||||||
"settings-sidebar-toggle": "6be9025ab24bc434",
|
"settings-sidebar-toggle": "6be9025ab24bc434",
|
||||||
"sidebar": "0dbf510da61afa07",
|
"sidebar": "77ec42a646a8db66",
|
||||||
"sidebar-brand": "d3d7ef4b21514e80",
|
"sidebar-brand": "d3d7ef4b21514e80",
|
||||||
"sidebar-new-chat": "567fae30048b7ff3",
|
"sidebar-new-chat": "567fae30048b7ff3",
|
||||||
"sidebar-resize-handle": "6607b819ee45409d",
|
"sidebar-resize-handle": "6607b819ee45409d",
|
||||||
@@ -399,10 +399,10 @@
|
|||||||
".ge-adj-icon": "405c77944c2f21c7",
|
".ge-adj-icon": "405c77944c2f21c7",
|
||||||
".ge-adj-popup": "e1a93233ab11f043",
|
".ge-adj-popup": "e1a93233ab11f043",
|
||||||
".ge-adj-row": "fed828d535ba710b",
|
".ge-adj-row": "fed828d535ba710b",
|
||||||
".ge-adj-row > label": "92541b678e8ddd3e",
|
".ge-adj-row > label": "21bd09c7cf6167e5",
|
||||||
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
|
".ge-adj-row input[type=\"range\"]": "3f1690aad291a7da",
|
||||||
".ge-adj-title": "7609b8e4f862ad23",
|
".ge-adj-title": "7609b8e4f862ad23",
|
||||||
".ge-ai-command-collapsed": "5a38c8ab93d1818f",
|
".ge-ai-command-collapsed": "35a892b0246538ba",
|
||||||
".ge-alpha-badge": "59eb1bb7eddf6df0",
|
".ge-alpha-badge": "59eb1bb7eddf6df0",
|
||||||
".ge-canvas-area": "b6a7426fcd6ad230",
|
".ge-canvas-area": "b6a7426fcd6ad230",
|
||||||
".ge-canvas-prompt": "97ddee153cdaf1f2",
|
".ge-canvas-prompt": "97ddee153cdaf1f2",
|
||||||
@@ -430,11 +430,11 @@
|
|||||||
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
|
".ge-layer-group-row .ge-layer-group-toggle": "cb04950c0a50aacb",
|
||||||
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
|
".ge-layer-group-row .ge-layer-lock-btn": "bb0f88ce29ae7637",
|
||||||
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
|
".ge-layer-group-row .ge-layer-opacity-row": "e697b236b3b4ba6e",
|
||||||
".ge-layer-group-row .ge-layer-vis": "ac882350a59601a1",
|
".ge-layer-group-row .ge-layer-vis": "2e6dbbab8d823b4f",
|
||||||
".ge-layer-lock-menu": "528deb6e31022e89",
|
".ge-layer-lock-menu": "528deb6e31022e89",
|
||||||
".ge-layer-lock-option": "38a3da4a8785330a",
|
".ge-layer-lock-option": "38a3da4a8785330a",
|
||||||
".ge-layers-grab": "094e764489b1b069",
|
".ge-layers-grab": "094e764489b1b069",
|
||||||
".ge-layers-header": "1793db7034e4b67b",
|
".ge-layers-header": "459d17627f2e83e6",
|
||||||
".ge-layers-list": "0b4bfbd17d414d6d",
|
".ge-layers-list": "0b4bfbd17d414d6d",
|
||||||
".ge-main-canvas": "274650d62945ac12",
|
".ge-main-canvas": "274650d62945ac12",
|
||||||
".ge-mask-sub-item": "546f03f9c46eb507",
|
".ge-mask-sub-item": "546f03f9c46eb507",
|
||||||
@@ -442,13 +442,13 @@
|
|||||||
".ge-section-help": "8bd5ecd734f48ca2",
|
".ge-section-help": "8bd5ecd734f48ca2",
|
||||||
".ge-shortcuts-grid": "84d3ab6453dab9b3",
|
".ge-shortcuts-grid": "84d3ab6453dab9b3",
|
||||||
".ge-slider-bubble": "2bc54e40357f68cf",
|
".ge-slider-bubble": "2bc54e40357f68cf",
|
||||||
".ge-tool-btn": "67f8bb05e2438d92",
|
".ge-tool-btn": "fd54cca726e51429",
|
||||||
".ge-tool-sep": "4ae61e4e4eddd504",
|
".ge-tool-sep": "4ae61e4e4eddd504",
|
||||||
".ge-toolbar": "0e05d7a6bc8d85c0",
|
".ge-toolbar": "ca827cf012ad8863",
|
||||||
".ge-topbar": "cdb801be32727e0b",
|
".ge-topbar": "6c39a6ade412d5fa",
|
||||||
".ge-transform-field": "76a785f630632652",
|
".ge-transform-field": "76a785f630632652",
|
||||||
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
|
".ge-transform-field > input.ge-transform-popup-input": "b870e4e9d5fc2d91",
|
||||||
".ge-transform-popup-body": "af7f9dceddb20374",
|
".ge-transform-popup-body": "d9d70292e5be7acc",
|
||||||
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
|
".ge-transform-popup-head .ge-adj-icon": "88ac29d44343e6fa",
|
||||||
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
|
".ge-transform-popup-head .ge-adj-title": "1d6ca66f2c39c9ef",
|
||||||
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
|
".ge-transform-popup-head .ge-head-btns": "ecd0a09cb27407d7",
|
||||||
@@ -456,9 +456,9 @@
|
|||||||
".ge-transform-popup-suffix": "d55491303f919f61",
|
".ge-transform-popup-suffix": "d55491303f919f61",
|
||||||
".ge-transform-quick-btn": "e74ea16e29b9b001",
|
".ge-transform-quick-btn": "e74ea16e29b9b001",
|
||||||
".ge-transform-spin": "22dc5f2bfab2ec9a",
|
".ge-transform-spin": "22dc5f2bfab2ec9a",
|
||||||
".ge-transform-spin button": "ad94130c6f1829d9",
|
".ge-transform-spin button": "527810c9f1ac1be5",
|
||||||
".ge-transform-spin button[data-spin=\"down\"]": "9cb76293b224cf00",
|
".ge-transform-spin button[data-spin=\"down\"]": "c0aff9fcdb755ed2",
|
||||||
".ge-transform-spin button[data-spin=\"up\"]": "4f6f103a02c3838d",
|
".ge-transform-spin button[data-spin=\"up\"]": "4fccf8fbe6c04fa9",
|
||||||
".hamburger": "2b0e4a6cb3333ded",
|
".hamburger": "2b0e4a6cb3333ded",
|
||||||
".hamburger span": "6e2fee4770672a20",
|
".hamburger span": "6e2fee4770672a20",
|
||||||
".hamburger-btn": "dc0f91fb82ccd472",
|
".hamburger-btn": "dc0f91fb82ccd472",
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
|
".hwfit-serve-topline .hwfit-serve-runtime-note": "15b3fef4e5ceaffc",
|
||||||
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
|
".hwfit-serve-topline .hwfit-serve-runtime-text": "57c6822b0d13147d",
|
||||||
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
|
".hwfit-spec-group .hwfit-spec-method": "5d1c329116272cf1",
|
||||||
".icon-rail": "c800b4ef07f5f1f0",
|
".icon-rail": "e3f72205260a4a5f",
|
||||||
".icon-rail-btn": "a5f207c3be15401e",
|
".icon-rail-btn": "a5f207c3be15401e",
|
||||||
".incognito-btn": "9833a2e86fab6a94",
|
".incognito-btn": "9833a2e86fab6a94",
|
||||||
".incognito-indicator": "b3945c46844c7278",
|
".incognito-indicator": "b3945c46844c7278",
|
||||||
@@ -617,15 +617,15 @@
|
|||||||
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
|
".settings-sidebar.settings-sidebar-collapsed": "d9d8f25df95d9ad8",
|
||||||
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
|
".settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content": "608e51170864586a",
|
||||||
".settings-system-logs-tab": "d0d90a13ff327ace",
|
".settings-system-logs-tab": "d0d90a13ff327ace",
|
||||||
".sidebar": "0dbf510da61afa07",
|
".sidebar": "77ec42a646a8db66",
|
||||||
".sidebar .section": "37fc733d59519a08",
|
".sidebar .section": "37fc733d59519a08",
|
||||||
".sidebar-brand-title": "2b20f75768b50346",
|
".sidebar-brand-title": "2b20f75768b50346",
|
||||||
".sidebar-header": "0d58816ca65b7216",
|
".sidebar-header": "0d58816ca65b7216",
|
||||||
".sidebar-inner": "ad5e28fedbfeb5db",
|
".sidebar-inner": "ad5e28fedbfeb5db",
|
||||||
".sidebar-notif-dot": "2673e215d0cd84a6",
|
".sidebar-notif-dot": "2673e215d0cd84a6",
|
||||||
".sidebar-user-bar": "b77372dbdcb069ef",
|
".sidebar-user-bar": "b77372dbdcb069ef",
|
||||||
".sidebar.hidden": "9021ec7e4e052131",
|
".sidebar.hidden": "0df301b638e2f93f",
|
||||||
".sidebar.right-side": "bb6ed4427d07657e",
|
".sidebar.right-side": "2b6bcc7d59f7b6bd",
|
||||||
".skill-card > .skill-card-header": "e2408e762818d2fb",
|
".skill-card > .skill-card-header": "e2408e762818d2fb",
|
||||||
".skill-card-tags": "a327c85e053e6d3f",
|
".skill-card-tags": "a327c85e053e6d3f",
|
||||||
".skill-card-textcol": "f1059ed37c120b60",
|
".skill-card-textcol": "f1059ed37c120b60",
|
||||||
@@ -686,56 +686,56 @@
|
|||||||
},
|
},
|
||||||
"variants": {
|
"variants": {
|
||||||
"app-shell": {
|
"app-shell": {
|
||||||
"desktop-dark-comfortable": "6ccb1b611fff1417",
|
"desktop-dark-comfortable": "0c9123117135e362",
|
||||||
"desktop-dark-compact": "fa8811351d590698",
|
"desktop-dark-compact": "c694f5d5118ac0e2",
|
||||||
"desktop-dark-spacious": "8b23cc34d8dacb5d",
|
"desktop-dark-spacious": "90a67613e8cfdf5d",
|
||||||
"desktop-light-comfortable": "4a98edc12f46490e",
|
"desktop-light-comfortable": "17727a9945ccc77b",
|
||||||
"desktop-light-compact": "3712d798c576cf07",
|
"desktop-light-compact": "2a887c30d231e357",
|
||||||
"desktop-light-spacious": "4aee43267423df1f",
|
"desktop-light-spacious": "94b4666734b87625",
|
||||||
"laptop-dark-comfortable": "35682719e8aeea91",
|
"laptop-dark-comfortable": "26d334b139b653f2",
|
||||||
"laptop-dark-compact": "a113f77e47ef20d6",
|
"laptop-dark-compact": "fea30bad6824357d",
|
||||||
"laptop-dark-spacious": "a94cb0602b8ebf8d",
|
"laptop-dark-spacious": "4fdb28deaaaec870",
|
||||||
"laptop-light-comfortable": "b523ba8c4d5f5180",
|
"laptop-light-comfortable": "c7fc986a7e70aac8",
|
||||||
"laptop-light-compact": "aff4e1e8a5c192a8",
|
"laptop-light-compact": "147645431229ddff",
|
||||||
"laptop-light-spacious": "dc83f76cba448444",
|
"laptop-light-spacious": "d513c285d30c8289",
|
||||||
"phone-dark-comfortable": "10af205a321eb35d",
|
"phone-dark-comfortable": "551761344bec8d09",
|
||||||
"phone-dark-compact": "1f2816f672a2ff9f",
|
"phone-dark-compact": "293d66518c3ec994",
|
||||||
"phone-dark-spacious": "10af205a321eb35d",
|
"phone-dark-spacious": "551761344bec8d09",
|
||||||
"phone-light-comfortable": "b781b1eec0f33d3e",
|
"phone-light-comfortable": "cb41acd3f9af3578",
|
||||||
"phone-light-compact": "131a7b11605617bb",
|
"phone-light-compact": "dbef6e9e373e9c10",
|
||||||
"phone-light-spacious": "b781b1eec0f33d3e",
|
"phone-light-spacious": "cb41acd3f9af3578",
|
||||||
"tablet-dark-comfortable": "4e005f33938a3026",
|
"tablet-dark-comfortable": "0f682621b4ead393",
|
||||||
"tablet-dark-compact": "3a389740245ce8e9",
|
"tablet-dark-compact": "e6f3fece5e716a2c",
|
||||||
"tablet-dark-spacious": "4e005f33938a3026",
|
"tablet-dark-spacious": "0f682621b4ead393",
|
||||||
"tablet-light-comfortable": "e7d5a710864503d3",
|
"tablet-light-comfortable": "6e398df9ca69c58b",
|
||||||
"tablet-light-compact": "e65e1bcd50cebf33",
|
"tablet-light-compact": "dca75981c8675bfa",
|
||||||
"tablet-light-spacious": "e7d5a710864503d3"
|
"tablet-light-spacious": "6e398df9ca69c58b"
|
||||||
},
|
},
|
||||||
"bench": {
|
"bench": {
|
||||||
"desktop-dark-comfortable": "48a5eeb1259fd422",
|
"desktop-dark-comfortable": "a9709cb47eec7544",
|
||||||
"desktop-dark-compact": "cef4e9f2ceacefbb",
|
"desktop-dark-compact": "bfab96cb72fd6faf",
|
||||||
"desktop-dark-spacious": "d87bef476928e845",
|
"desktop-dark-spacious": "25469178b443428a",
|
||||||
"desktop-light-comfortable": "69a68072200b3669",
|
"desktop-light-comfortable": "2166794d643fae4c",
|
||||||
"desktop-light-compact": "9c6deaecd2265ad9",
|
"desktop-light-compact": "81d8ab64407b1d9a",
|
||||||
"desktop-light-spacious": "8a355029e7261110",
|
"desktop-light-spacious": "d6d19f33476665af",
|
||||||
"laptop-dark-comfortable": "aaf69c3500c38635",
|
"laptop-dark-comfortable": "57169e64e1fb5324",
|
||||||
"laptop-dark-compact": "ca5a5cb542ee1901",
|
"laptop-dark-compact": "6ac903cd8a711bcf",
|
||||||
"laptop-dark-spacious": "9f85415f735e040e",
|
"laptop-dark-spacious": "a47535332e9c5de7",
|
||||||
"laptop-light-comfortable": "06caf416811d5778",
|
"laptop-light-comfortable": "1e2b381de3556fac",
|
||||||
"laptop-light-compact": "f2bab4d31395dd49",
|
"laptop-light-compact": "6dbfb465121949c1",
|
||||||
"laptop-light-spacious": "6c56a4469a2b42ff",
|
"laptop-light-spacious": "1b8bfcd4bc4ed214",
|
||||||
"phone-dark-comfortable": "eb27d09bf0ff4ca1",
|
"phone-dark-comfortable": "67ca3c78e6b9495a",
|
||||||
"phone-dark-compact": "e60d82de1d36c09b",
|
"phone-dark-compact": "ad6f3a9f5c8d81cd",
|
||||||
"phone-dark-spacious": "3578747ada4bcbb2",
|
"phone-dark-spacious": "1e6f66e56519ba5d",
|
||||||
"phone-light-comfortable": "8b0b9f1e7d73d36f",
|
"phone-light-comfortable": "ef876073cc2dc45e",
|
||||||
"phone-light-compact": "d2f58cb1c1675907",
|
"phone-light-compact": "2ef6f5dc7a646be9",
|
||||||
"phone-light-spacious": "edae71cdde26fd58",
|
"phone-light-spacious": "b0d4cd9bd0361966",
|
||||||
"tablet-dark-comfortable": "8fc2eb75bed6417d",
|
"tablet-dark-comfortable": "58922240b216e786",
|
||||||
"tablet-dark-compact": "2fd7fb77dfb379ba",
|
"tablet-dark-compact": "3991c22f99a394e0",
|
||||||
"tablet-dark-spacious": "1376b658cfc7cb4b",
|
"tablet-dark-spacious": "9848c3b7debac974",
|
||||||
"tablet-light-comfortable": "b456d13e3bbf627e",
|
"tablet-light-comfortable": "12dfbad36172de20",
|
||||||
"tablet-light-compact": "44eb1164a0db4654",
|
"tablet-light-compact": "80d4843e622f1353",
|
||||||
"tablet-light-spacious": "5d999940aa547f86"
|
"tablet-light-spacious": "c420b8d46c991a44"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"desktop-dark-comfortable": "3cf4809db0f8e119",
|
"desktop-dark-comfortable": "3cf4809db0f8e119",
|
||||||
|
|||||||
@@ -1,28 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN || 'http://127.0.0.1:7011';
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
try {
|
try {
|
||||||
for (const width of [390, 900]) {
|
for (const width of [390, 900]) {
|
||||||
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
|
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
|
||||||
if (!process.env.ODYSSEUS_TEST_STATIC_ORIGIN) {
|
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||||
await page.route(`${origin}/**`, async route => {
|
|
||||||
const url = new URL(route.request().url());
|
|
||||||
const rel = url.pathname.replace(/^\//, '');
|
|
||||||
try {
|
|
||||||
const file = new URL('../' + rel, import.meta.url);
|
|
||||||
const body = await readFile(file);
|
|
||||||
const ext = rel.split('.').pop();
|
|
||||||
const type = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'application/octet-stream';
|
|
||||||
return route.fulfill({ contentType: type, body });
|
|
||||||
} catch {
|
|
||||||
return route.fulfill({ status: 404, body: 'Not found' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await page.goto(`${origin}/static/js/documentStats.js`);
|
|
||||||
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const mod = await import('/static/js/document.js?mobile-export-regression=1');
|
const mod = await import('/static/js/document.js?mobile-export-regression=1');
|
||||||
|
|||||||
+2
-7
@@ -1,7 +1,7 @@
|
|||||||
# Browser End-to-End Tests
|
# Browser End-to-End Tests
|
||||||
|
|
||||||
The photo editor release gate launches an isolated Odysseus server on port
|
The photo editor release gate launches an isolated, authentication-disabled
|
||||||
`7013` with a temporary SQLite database and disposable authenticated account.
|
Odysseus server on port `7013` with a temporary SQLite database.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -13,8 +13,3 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
|
|||||||
are written to `test-results/photo-editor/`. The server uses the repository
|
are written to `test-results/photo-editor/`. The server uses the repository
|
||||||
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
|
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
|
||||||
when using a different environment layout.
|
when using a different environment layout.
|
||||||
|
|
||||||
The disposable account is created in the temporary data directory and exercises
|
|
||||||
owner-scoped draft and notification routes without using an existing account.
|
|
||||||
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
|
|
||||||
investigating it; offline-upgrade coverage requires the authenticated mode.
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
const { selectTool } = require('./helpers.js');
|
||||||
const {
|
const {
|
||||||
dragOnCanvas,
|
dragOnCanvas,
|
||||||
encodedImagePixelDigest,
|
encodedImagePixelDigest,
|
||||||
@@ -11,7 +11,7 @@ const {
|
|||||||
} = require('./helpers.js');
|
} = require('./helpers.js');
|
||||||
|
|
||||||
async function addAdjustment(page, type) {
|
async function addAdjustment(page, type) {
|
||||||
await page.locator('#ge-add-fx').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
const menu = page.locator('.ge-add-layer-menu');
|
const menu = page.locator('.ge-add-layer-menu');
|
||||||
await expect(menu).toBeVisible();
|
await expect(menu).toBeVisible();
|
||||||
await menu.locator(`[data-adjustment-type="${type}"]`).click();
|
await menu.locator(`[data-adjustment-type="${type}"]`).click();
|
||||||
@@ -252,7 +252,8 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
|
|||||||
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
|
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
|
||||||
const before = await flattenedPixelDigest(page);
|
const before = await flattenedPixelDigest(page);
|
||||||
|
|
||||||
await chooseFilterAction(page, 'effect-blur-gaussian');
|
await page.locator('#ge-filter-menu-btn').click();
|
||||||
|
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
||||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||||
@@ -281,7 +282,8 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
|
|||||||
|
|
||||||
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
|
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
|
||||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
|
||||||
await chooseFilterAction(page, 'effect-preset-crisp-detail');
|
await page.locator('#ge-filter-menu-btn').click();
|
||||||
|
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
|
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
|
||||||
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
|
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
|
||||||
@@ -306,14 +308,16 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
|
|||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
|
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await chooseFilterAction(page, 'effect-color-overlay');
|
await page.locator('#ge-filter-menu-btn').click();
|
||||||
|
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
||||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
||||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||||
await page.locator('button[title="Add effect mask from selection"]').click();
|
await page.locator('button[title="Add effect mask from selection"]').click();
|
||||||
|
|
||||||
await chooseFilterAction(page, 'effect-drop-shadow');
|
await page.locator('#ge-filter-menu-btn').click();
|
||||||
|
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
|
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
|
||||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||||
@@ -344,7 +348,8 @@ test('retained Stroke uses layer alpha and persists its controls', async ({ page
|
|||||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
|
||||||
await selectTool(page, 'brush');
|
await selectTool(page, 'brush');
|
||||||
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
|
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
|
||||||
await chooseFilterAction(page, 'effect-stroke');
|
await page.locator('#ge-filter-menu-btn').click();
|
||||||
|
await page.locator('[data-filter-action="effect-stroke"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
|
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
|
||||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
// These tests borrow /login as a lightweight page. A signed-in session would
|
|
||||||
// make the login page redirect to the app mid-test, so run them signed out.
|
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
|
||||||
|
|
||||||
async function addAppStyles(page) {
|
async function addAppStyles(page) {
|
||||||
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
|
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
|
||||||
for (const url of await stylesheetUrls()) {
|
for (const url of await stylesheetUrls()) {
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
|
|||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(end.x, end.y, { steps: 240 });
|
await page.mouse.move(end.x, end.y, { steps: 240 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
const strokeDuration = Date.now() - startedAt;
|
expect(Date.now() - startedAt).toBeLessThan(5000);
|
||||||
expect(strokeDuration).toBeLessThan(5000);
|
|
||||||
const after = await flattenedPixelDigest(page);
|
const after = await flattenedPixelDigest(page);
|
||||||
expect(after).not.toEqual(before);
|
expect(after).not.toEqual(before);
|
||||||
await expect(thumb).toHaveAttribute('width', '68');
|
await expect(thumb).toHaveAttribute('width', '68');
|
||||||
@@ -79,17 +78,14 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
|
|||||||
await selectTool(page, 'eyedropper');
|
await selectTool(page, 'eyedropper');
|
||||||
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
|
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
|
||||||
await page.locator('#ge-eyedropper-sample').selectOption('composite');
|
await page.locator('#ge-eyedropper-sample').selectOption('composite');
|
||||||
// The options strip height differs per tool, which re-centres the canvas a
|
await page.mouse.move(sample.x, sample.y);
|
||||||
// few pixels; hover the same image point that was painted.
|
|
||||||
const painted = await canvasPoint(page, 0.3, 0.3);
|
|
||||||
await page.mouse.move(painted.x, painted.y);
|
|
||||||
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
|
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
|
||||||
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
|
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
|
||||||
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
|
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
|
||||||
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
|
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
|
||||||
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
|
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
|
||||||
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
|
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
|
||||||
await page.mouse.click(painted.x, painted.y);
|
await page.mouse.click(sample.x, sample.y);
|
||||||
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
|
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
|
||||||
|
|
||||||
for (const tool of ['dodge', 'burn']) {
|
for (const tool of ['dodge', 'burn']) {
|
||||||
@@ -182,11 +178,7 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
|
|||||||
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
|
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
|
||||||
});
|
});
|
||||||
await selectTool(page, 'smudge');
|
await selectTool(page, 'smudge');
|
||||||
// Smudge strength lives in the brush-family "More" menu with the other
|
|
||||||
// per-tool stroke modifiers.
|
|
||||||
await page.locator('#ge-brush-more').click();
|
|
||||||
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
|
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.mouse.move(source.x, source.y);
|
await page.mouse.move(source.x, source.y);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.mouse.move(target.x, target.y, { steps: 20 });
|
await page.mouse.move(target.x, target.y, { steps: 20 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { selectTool } = require('./helpers.js');
|
const { selectTool } = require('./helpers.js');
|
||||||
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = require('./helpers.js');
|
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||||
|
|
||||||
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
|
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
|
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
|
||||||
@@ -75,15 +75,13 @@ test('touch crop and selection gestures complete through the shared lifecycle',
|
|||||||
try {
|
try {
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
|
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
|
||||||
const cdp = await context.newCDPSession(page);
|
const cdp = await context.newCDPSession(page);
|
||||||
|
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||||
type,
|
type,
|
||||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await selectTool(page, 'crop');
|
await selectTool(page, 'crop');
|
||||||
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
|
|
||||||
// aim at where the canvas settles, as a finger would.
|
|
||||||
const canvasBox = await settledCanvasBox(page);
|
|
||||||
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
|
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
|
||||||
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
|
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
|
||||||
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
|
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
|
||||||
@@ -115,24 +113,18 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
|
|||||||
try {
|
try {
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
|
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
|
||||||
const cdp = await context.newCDPSession(page);
|
const cdp = await context.newCDPSession(page);
|
||||||
|
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
|
||||||
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
|
||||||
type,
|
type,
|
||||||
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
|
||||||
});
|
});
|
||||||
await selectTool(page, 'brush');
|
await selectTool(page, 'brush');
|
||||||
// Re-tapping the active tool collapses its controls sheet (canvas-first
|
|
||||||
// phone editing), which also uncovers the Layers sheet grab handle.
|
|
||||||
await selectTool(page, 'brush');
|
|
||||||
const canvasBox = await settledCanvasBox(page);
|
|
||||||
const before = await flattenedPixelDigest(page);
|
const before = await flattenedPixelDigest(page);
|
||||||
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
|
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
|
||||||
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||||
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
|
||||||
const after = await flattenedPixelDigest(page);
|
const after = await flattenedPixelDigest(page);
|
||||||
expect(after).not.toEqual(before);
|
expect(after).not.toEqual(before);
|
||||||
// History buttons live in the Layers sheet, which the tool pick minimized.
|
|
||||||
await page.locator('.ge-layers-grab').click();
|
|
||||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
|
||||||
await page.locator('#ge-undo').click();
|
await page.locator('#ge-undo').click();
|
||||||
expect(await flattenedPixelDigest(page)).toEqual(before);
|
expect(await flattenedPixelDigest(page)).toEqual(before);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async function downloadFormat(page, format, filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addAdjustment(page, type) {
|
async function addAdjustment(page, type) {
|
||||||
await page.locator('#ge-add-fx').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
|
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
|
||||||
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
|
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
|
||||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||||
@@ -100,7 +100,7 @@ test('composited correction exports preserve pixels and format metadata', async
|
|||||||
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
|
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
|
||||||
await seedGradient(page);
|
await seedGradient(page);
|
||||||
|
|
||||||
await page.locator('#ge-add-fx').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
||||||
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
|
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
|
||||||
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
|
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { chooseFilterAction, selectTool } = require('./helpers.js');
|
const { selectTool } = require('./helpers.js');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const {
|
const {
|
||||||
compareExportPixels,
|
compareExportPixels,
|
||||||
@@ -38,7 +38,8 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
|
|||||||
const groupId = (await editorState(page)).groups[0].id;
|
const groupId = (await editorState(page)).groups[0].id;
|
||||||
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
|
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
|
||||||
|
|
||||||
await chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
|
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
|
||||||
|
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
|
||||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||||
@@ -83,7 +84,8 @@ test('group retained effects export the visible composite and survive reopen', a
|
|||||||
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
|
||||||
await page.locator('#ge-group-selected').click();
|
await page.locator('#ge-group-selected').click();
|
||||||
|
|
||||||
await chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
|
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
|
||||||
|
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
|
||||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
|
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test('group drag reorders the complete subtree with undo and server persistence'
|
|||||||
const originalOrder = current.layers.map(layer => layer.id);
|
const originalOrder = current.layers.map(layer => layer.id);
|
||||||
|
|
||||||
await page.locator('#ge-add-layer').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
|
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||||
current = await editorState(page);
|
current = await editorState(page);
|
||||||
const looseId = current.activeLayerId;
|
const looseId = current.activeLayerId;
|
||||||
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
|
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
|
||||||
@@ -65,7 +66,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
|
|||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
try {
|
try {
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
|
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
|
||||||
await page.locator('.ge-layers-title').click();
|
await page.locator('.ge-layers-header').click();
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
||||||
const before = await editorState(page);
|
const before = await editorState(page);
|
||||||
// State is bottom-to-top; the panel is top-to-bottom.
|
// State is bottom-to-top; the panel is top-to-bottom.
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
const { expect } = require('@playwright/test');
|
const { expect } = require('@playwright/test');
|
||||||
|
|
||||||
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
|
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
let lastError = null;
|
||||||
// Workflow tests exercise the canvas, not the optional setup overlay.
|
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
|
||||||
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
|
try {
|
||||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.evaluate(async () => {
|
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
|
||||||
window.__photoEditorImport = path => import(path);
|
await page.evaluate(async () => {
|
||||||
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
|
window.__photoEditorImport = async path => {
|
||||||
gallery.openGallery();
|
let lastImportError = null;
|
||||||
});
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
|
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
|
||||||
await page.locator('#gallery-editor-tab').click();
|
try { return await import(url); } catch (error) { lastImportError = error; }
|
||||||
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
|
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
|
||||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
}
|
||||||
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
|
throw lastImportError;
|
||||||
}, { size, name });
|
};
|
||||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
|
||||||
|
gallery.openGallery();
|
||||||
|
});
|
||||||
|
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
|
||||||
|
await page.locator('#gallery-editor-tab').click();
|
||||||
|
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
|
||||||
|
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||||
|
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
|
||||||
|
}, { size, name });
|
||||||
|
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await page.goto('about:blank');
|
||||||
|
await page.waitForTimeout(250 * (pageAttempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError) throw lastError;
|
||||||
await expect.poll(async () => page.evaluate(async () => {
|
await expect.poll(async () => page.evaluate(async () => {
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
const { state } = await import('/static/js/editor/state.js');
|
||||||
return state.layers.length;
|
return state.layers.length;
|
||||||
@@ -350,51 +368,12 @@ async function selectTool(page, toolId) {
|
|||||||
await face.first().click();
|
await face.first().click();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Open the flyout via its corner triangle, wait for item visibility, and click realistically
|
// Flyout members are pre-rendered (hidden until the slot opens). Dispatch a
|
||||||
const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
|
// click directly so the helper does not depend on hover/flyout visibility.
|
||||||
const tri = slot.locator('.ge-tool-flyout-tri');
|
await page.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`).first().dispatchEvent('click');
|
||||||
if (await tri.count() > 0) {
|
|
||||||
await tri.click();
|
|
||||||
}
|
|
||||||
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
|
|
||||||
await expect(item).toBeVisible({ timeout: 3000 });
|
|
||||||
await item.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
|
|
||||||
// Wait for the canvas to stop moving before aiming at it.
|
|
||||||
async function settledCanvasBox(page) {
|
|
||||||
let box = null;
|
|
||||||
await expect.poll(async () => {
|
|
||||||
const previous = box;
|
|
||||||
box = await page.locator('.ge-main-canvas').boundingBox();
|
|
||||||
return JSON.stringify(previous) === JSON.stringify(box);
|
|
||||||
}).toBe(true);
|
|
||||||
return box;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rare text/shape options live in the options strip's "More" popover, which
|
|
||||||
// closes on any outside press. Open it (idempotently) before using them.
|
|
||||||
async function openOptionsMore(page, kind) {
|
|
||||||
const menu = page.locator(`#ge-${kind}-more`);
|
|
||||||
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
|
|
||||||
await expect(menu).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
|
|
||||||
// Open the menu, then the item's parent row, as a user would.
|
|
||||||
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
|
|
||||||
await page.locator(opener).click();
|
|
||||||
const menu = page.locator('#ge-filter-menu');
|
|
||||||
await expect(menu).toBeVisible();
|
|
||||||
const item = menu.locator(`[data-filter-action="${action}"]`);
|
|
||||||
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
|
|
||||||
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
|
|
||||||
await item.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
chooseFilterAction,
|
|
||||||
compareExportPixels,
|
compareExportPixels,
|
||||||
dragOnCanvas,
|
dragOnCanvas,
|
||||||
encodedImagePixelDigest,
|
encodedImagePixelDigest,
|
||||||
@@ -402,9 +381,7 @@ module.exports = {
|
|||||||
flattenedPixelDigest,
|
flattenedPixelDigest,
|
||||||
openBlankEditor,
|
openBlankEditor,
|
||||||
openExportDialog,
|
openExportDialog,
|
||||||
openOptionsMore,
|
|
||||||
reopenDraft,
|
reopenDraft,
|
||||||
selectTool,
|
selectTool,
|
||||||
settledCanvasBox,
|
|
||||||
waitForDraft,
|
waitForDraft,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { chooseFilterAction, dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
|
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers');
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// The isolated editor server has no logged-in notification owner.
|
// The isolated editor server has no logged-in notification owner.
|
||||||
@@ -35,96 +35,6 @@ test('top toolbar stays on one row and its menus remain clickable at narrow widt
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save button icon follows the draft status through an edit and autosave', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
|
||||||
// Autosave can settle within a frame, so record the rendered icon at every
|
|
||||||
// status change instead of racing it with polling assertions.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const status = document.getElementById('ge-draft-status');
|
|
||||||
const visibleIcon = () => [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')]
|
|
||||||
.filter(icon => icon.getBoundingClientRect().width > 0)
|
|
||||||
.map(icon => (icon.classList.contains('ge-save-state-saved') ? 'saved' : 'dirty'));
|
|
||||||
window.__saveStates = [];
|
|
||||||
new MutationObserver(() => window.__saveStates.push({ text: status.textContent, icons: visibleIcon() }))
|
|
||||||
.observe(status, { childList: true, characterData: true, subtree: true });
|
|
||||||
});
|
|
||||||
await page.keyboard.press('b');
|
|
||||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
|
||||||
await waitForDraft(page);
|
|
||||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
|
||||||
const states = await page.evaluate(() => window.__saveStates);
|
|
||||||
expect(states.some(entry => entry.text === 'Unsaved')).toBe(true);
|
|
||||||
for (const entry of states) {
|
|
||||||
expect(entry.icons).toEqual([entry.text === 'Saved' ? 'saved' : 'dirty']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('save-state representations cover all draft states without error normalization', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
|
||||||
await waitForDraft(page);
|
|
||||||
|
|
||||||
const cases = [
|
|
||||||
{ label: 'Unsaved', state: 'dirty', expectedText: 'Unsaved', expectedState: 'dirty', iconClass: 'ge-save-state-dirty', isError: false },
|
|
||||||
{ label: 'Saving soon…', state: 'pending', expectedText: 'Saving soon…', expectedState: 'pending', iconClass: 'ge-save-state-dirty', isError: false },
|
|
||||||
{ label: 'Saving…', state: 'saving', expectedText: 'Saving…', expectedState: 'saving', iconClass: 'ge-save-state-dirty', isError: false },
|
|
||||||
{ label: 'Saved', state: 'saved', expectedText: 'Saved', expectedState: 'saved', iconClass: 'ge-save-state-saved', isError: false },
|
|
||||||
{ label: 'Save failed', state: 'error', expectedText: 'Save failed', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
|
|
||||||
{ label: 'Save paused', state: 'paused', expectedText: 'Save paused', expectedState: 'paused', iconClass: 'ge-save-state-error', isError: true },
|
|
||||||
{ label: 'Save failed: network offline', state: '', expectedText: 'Save failed: network offline', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const c of cases) {
|
|
||||||
await page.evaluate(({ label, state }) => {
|
|
||||||
window.__geSetDraftStatus(label, state);
|
|
||||||
}, c);
|
|
||||||
|
|
||||||
const statusEl = page.locator('#ge-draft-status');
|
|
||||||
const saveBtn = page.locator('#ge-save-menu-btn');
|
|
||||||
await expect(statusEl).toHaveText(c.expectedText);
|
|
||||||
await expect(statusEl).toHaveAttribute('data-state', c.expectedState);
|
|
||||||
|
|
||||||
const visibleIcon = await page.evaluate(() => {
|
|
||||||
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
|
|
||||||
const visible = icons.filter(i => !i.hasAttribute('hidden'));
|
|
||||||
return visible.map(i => i.className.baseVal || i.className);
|
|
||||||
});
|
|
||||||
expect(visibleIcon.length).toBe(1);
|
|
||||||
expect(visibleIcon[0]).toContain(c.iconClass);
|
|
||||||
|
|
||||||
if (c.isError) {
|
|
||||||
await expect(saveBtn).toHaveAttribute('aria-label', new RegExp(`Draft autosave failed: ${c.expectedText}`));
|
|
||||||
} else {
|
|
||||||
await expect(saveBtn).toHaveAttribute('aria-label', `Draft status: ${c.expectedText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('autosave network failure transitions to error state and shows failure indicator', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
|
||||||
await page.route(/\/api\/editor-drafts/, route => route.fulfill({
|
|
||||||
status: 500,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ detail: 'Database error' }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await page.keyboard.press('b');
|
|
||||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
|
|
||||||
|
|
||||||
const statusEl = page.locator('#ge-draft-status');
|
|
||||||
await expect(statusEl).toHaveText('Save failed', { timeout: 10_000 });
|
|
||||||
await expect(statusEl).toHaveAttribute('data-state', 'error');
|
|
||||||
|
|
||||||
const saveBtn = page.locator('#ge-save-menu-btn');
|
|
||||||
await expect(saveBtn).toHaveAttribute('aria-label', /Draft autosave failed/);
|
|
||||||
|
|
||||||
const visibleIcons = await page.evaluate(() => {
|
|
||||||
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
|
|
||||||
return icons.filter(i => !i.hasAttribute('hidden')).map(i => i.className.baseVal || i.className);
|
|
||||||
});
|
|
||||||
expect(visibleIcons).toHaveLength(1);
|
|
||||||
expect(visibleIcons[0]).toContain('ge-save-state-error');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
|
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
await openBlankEditor(page, { width: 320, height: 240 });
|
||||||
const activeId = (await editorState(page)).activeLayerId;
|
const activeId = (await editorState(page)).activeLayerId;
|
||||||
@@ -145,7 +55,8 @@ test('filter Escape restores pixels and preserves redo history', async ({ page }
|
|||||||
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
|
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
|
||||||
await page.keyboard.press('Control+z');
|
await page.keyboard.press('Control+z');
|
||||||
const before = await editorState(page);
|
const before = await editorState(page);
|
||||||
await chooseFilterAction(page, 'blur-gaussian');
|
await page.locator('#ge-filter-menu-btn').click();
|
||||||
|
await page.locator('[data-filter-action="blur-gaussian"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
|
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
|
||||||
@@ -164,10 +75,7 @@ test('focused fields own undo, selection and clipboard without changing layers',
|
|||||||
await input.fill('Editable text');
|
await input.fill('Editable text');
|
||||||
await input.press('Control+Enter');
|
await input.press('Control+Enter');
|
||||||
// A tool's ordinary text input must also retain native clipboard ownership.
|
// A tool's ordinary text input must also retain native clipboard ownership.
|
||||||
// Frame width lives in the text options' collapsed "More" section.
|
|
||||||
await page.locator('#ge-text-more-toggle').click();
|
|
||||||
await page.locator('#ge-text-frame-width').focus();
|
await page.locator('#ge-text-frame-width').focus();
|
||||||
await expect(page.locator('#ge-text-frame-width')).toBeFocused();
|
|
||||||
const before = await editorState(page);
|
const before = await editorState(page);
|
||||||
await page.keyboard.press('Control+z');
|
await page.keyboard.press('Control+z');
|
||||||
await page.keyboard.press('Control+j');
|
await page.keyboard.press('Control+j');
|
||||||
@@ -339,76 +247,3 @@ test('switching away from a held brush ends one stroke and desktop reselect keep
|
|||||||
await page.keyboard.press('Control+z');
|
await page.keyboard.press('Control+z');
|
||||||
expect((await editorState(page)).layers).toEqual(before.layers);
|
expect((await editorState(page)).layers).toEqual(before.layers);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('concurrent edits during in-flight save preserve dirty unsaved status and revision sequence', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
|
||||||
await waitForDraft(page);
|
|
||||||
|
|
||||||
let delayedRouteResolve = null;
|
|
||||||
const delayedRoutePromise = new Promise(resolve => { delayedRouteResolve = resolve; });
|
|
||||||
|
|
||||||
let saveCallCount = 0;
|
|
||||||
await page.route(/\/api\/editor-drafts/, async route => {
|
|
||||||
saveCallCount += 1;
|
|
||||||
if (saveCallCount === 1) {
|
|
||||||
await delayedRoutePromise;
|
|
||||||
}
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ id: 'concurrent-draft-1', name: 'Untitled' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Edit 1
|
|
||||||
await page.keyboard.press('b');
|
|
||||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
|
|
||||||
|
|
||||||
// Wait until save is in flight
|
|
||||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saving…', { timeout: 10_000 });
|
|
||||||
|
|
||||||
// Edit 2 while save 1 is in flight
|
|
||||||
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.7, y: 0.7 });
|
|
||||||
|
|
||||||
const revsDuringFlight = await page.evaluate(async () => {
|
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
|
||||||
return {
|
|
||||||
docRev: state.documentRevision,
|
|
||||||
persistingRev: state.persistingRevision,
|
|
||||||
dirty: state.persistDirty,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
expect(revsDuringFlight.docRev).toBeGreaterThan(revsDuringFlight.persistingRev);
|
|
||||||
expect(revsDuringFlight.dirty).toBe(true);
|
|
||||||
|
|
||||||
// Now let save 1 complete
|
|
||||||
delayedRouteResolve();
|
|
||||||
|
|
||||||
// Wait for the chained second save to complete
|
|
||||||
await expect.poll(async () => {
|
|
||||||
return page.evaluate(async () => {
|
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
|
||||||
return state.savedRevision >= state.documentRevision && !state.persistDirty;
|
|
||||||
});
|
|
||||||
}, { timeout: 15_000 }).toBe(true);
|
|
||||||
|
|
||||||
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('failed draft save retains unsaved dirty status and failure indicator', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
|
||||||
await waitForDraft(page);
|
|
||||||
|
|
||||||
await page.route(/\/api\/editor-drafts/, route => route.abort('failed'));
|
|
||||||
|
|
||||||
await page.keyboard.press('b');
|
|
||||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
|
|
||||||
|
|
||||||
await expect(page.locator('#ge-draft-status')).toHaveText('Save failed', { timeout: 10_000 });
|
|
||||||
|
|
||||||
const dirtyAfterFailure = await page.evaluate(async () => {
|
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
|
||||||
return state.persistDirty;
|
|
||||||
});
|
|
||||||
expect(dirtyAfterFailure).toBe(true);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const { selectTool } = require('./helpers.js');
|
|||||||
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||||
|
|
||||||
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
|
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
|
||||||
|
// Notification polling requires login even in the auth-disabled test server.
|
||||||
|
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
|
||||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
|
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
|
||||||
const id = await page.evaluate(async () => {
|
const id = await page.evaluate(async () => {
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
const { state } = await import('/static/js/editor/state.js');
|
||||||
@@ -30,6 +32,7 @@ test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden
|
|||||||
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
|
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
|
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
|
||||||
await page.locator('#ge-add-layer').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
|
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
const { state } = await import('/static/js/editor/state.js');
|
||||||
const edit = state.layers.find(layer => layer.name === 'Edit');
|
const edit = state.layers.find(layer => layer.name === 'Edit');
|
||||||
@@ -108,6 +111,7 @@ test('group rows can be selected with the keyboard', async ({ page }) => {
|
|||||||
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
|
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
|
||||||
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
|
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
|
||||||
await page.locator('#ge-add-layer').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
|
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||||
const addedId = (await editorState(page)).activeLayerId;
|
const addedId = (await editorState(page)).activeLayerId;
|
||||||
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
|
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
|
||||||
await page.keyboard.press('Backspace');
|
await page.keyboard.press('Backspace');
|
||||||
@@ -189,6 +193,7 @@ test('multi-selected layers move together and support history-backed bulk action
|
|||||||
|
|
||||||
await editRow.click();
|
await editRow.click();
|
||||||
await page.locator('#ge-add-layer').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
|
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||||
const addedId = (await editorState(page)).activeLayerId;
|
const addedId = (await editorState(page)).activeLayerId;
|
||||||
await editRow.click({ modifiers: ['Control'] });
|
await editRow.click({ modifiers: ['Control'] });
|
||||||
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
|
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
|
||||||
@@ -273,6 +278,7 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
|
|||||||
const innerId = current.groups[0].id;
|
const innerId = current.groups[0].id;
|
||||||
|
|
||||||
await page.locator('#ge-add-layer').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
|
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
|
||||||
current = await editorState(page);
|
current = await editorState(page);
|
||||||
const looseId = current.activeLayerId;
|
const looseId = current.activeLayerId;
|
||||||
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
|
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { chooseFilterAction, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
|
||||||
|
|
||||||
async function seedVisibleLayers(page) {
|
async function seedVisibleLayers(page) {
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
@@ -18,13 +18,14 @@ test('Merge all preserves retained effects and adjustment output', async ({ page
|
|||||||
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
|
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
|
||||||
await seedVisibleLayers(page);
|
await seedVisibleLayers(page);
|
||||||
|
|
||||||
await chooseFilterAction(page, 'effect-color-overlay');
|
await page.locator('#ge-filter-menu-btn').click();
|
||||||
|
await page.locator('[data-filter-action="effect-color-overlay"]').click();
|
||||||
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
await expect(page.locator('.ge-filter-modal')).toBeVisible();
|
||||||
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
|
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
|
||||||
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
|
||||||
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
await page.locator('.ge-filter-modal [data-action="apply"]').click();
|
||||||
|
|
||||||
await page.locator('#ge-add-fx').click();
|
await page.locator('#ge-add-layer').click();
|
||||||
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
|
||||||
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
await expect(page.locator('.ge-adj-popup')).toBeVisible();
|
||||||
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
|
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const { openBlankEditor, selectTool } = require('./helpers.js');
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
|
||||||
|
|
||||||
test('minimizing layers restores fit view and keeps an explicit zoom', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
|
||||||
const canvas = page.locator('.ge-main-canvas');
|
|
||||||
const zoom = () => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
|
|
||||||
await page.locator('.ge-layers-title').click();
|
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
|
||||||
await selectTool(page, 'eraser');
|
|
||||||
await selectTool(page, 'eraser');
|
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
|
||||||
await expect.poll(async () => (await canvas.boundingBox()).width).toBeGreaterThan(200);
|
|
||||||
|
|
||||||
await page.locator('.ge-layers-grab').click();
|
|
||||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
|
||||||
await page.locator('#ge-zoom-in').evaluate(button => button.click());
|
|
||||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoomViewMode)).toBe('scale');
|
|
||||||
const manualZoom = await zoom();
|
|
||||||
await page.locator('.ge-layers-title').click();
|
|
||||||
await selectTool(page, 'eraser');
|
|
||||||
await selectTool(page, 'eraser');
|
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
|
||||||
await expect.poll(zoom).toBe(manualZoom);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('minimizing layers keeps a pinch zoom', async ({ page, context, browserName }) => {
|
|
||||||
test.skip(browserName !== 'chromium', 'Uses Chromium CDP multi-touch injection');
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 });
|
|
||||||
const state = () => page.evaluate(async () => {
|
|
||||||
const { state: editor } = await import('/static/js/editor/state.js');
|
|
||||||
return { zoom: editor.zoom, mode: editor.zoomViewMode };
|
|
||||||
});
|
|
||||||
const fitted = await state();
|
|
||||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
|
||||||
const cx = box.x + box.width / 2;
|
|
||||||
const cy = box.y + box.height / 2;
|
|
||||||
const cdp = await context.newCDPSession(page);
|
|
||||||
const fingers = spread => [{ x: cx - spread, y: cy, id: 1 }, { x: cx + spread, y: cy, id: 2 }];
|
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: fingers(20) });
|
|
||||||
for (const spread of [30, 40, 50]) {
|
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: fingers(spread) });
|
|
||||||
}
|
|
||||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
|
||||||
const pinched = await state();
|
|
||||||
expect(pinched.mode).toBe('scale');
|
|
||||||
expect(pinched.zoom).toBeGreaterThan(fitted.zoom * 2);
|
|
||||||
|
|
||||||
await page.locator('.ge-layers-title').click();
|
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
|
|
||||||
await selectTool(page, 'eraser');
|
|
||||||
await selectTool(page, 'eraser');
|
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
|
|
||||||
await expect.poll(async () => (await state()).zoom).toBe(pinched.zoom);
|
|
||||||
});
|
|
||||||
@@ -121,10 +121,6 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
|
|||||||
const painted = await editorState(page);
|
const painted = await editorState(page);
|
||||||
const paintedLayer = painted.layers.find(layer => layer.masks.length);
|
const paintedLayer = painted.layers.find(layer => layer.masks.length);
|
||||||
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
|
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
|
||||||
// History controls live in the layers header, which is hidden while the
|
|
||||||
// sheet is minimized for painting. Reopen it as a phone user would.
|
|
||||||
await page.locator('.ge-layers-grab').click();
|
|
||||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
|
||||||
await page.locator('#ge-undo').click();
|
await page.locator('#ge-undo').click();
|
||||||
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
|
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
|
||||||
.toBe(before);
|
.toBe(before);
|
||||||
@@ -211,8 +207,6 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
|
|||||||
expect(movedLayer.offset).toEqual(beforeLayer.offset);
|
expect(movedLayer.offset).toEqual(beforeLayer.offset);
|
||||||
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
|
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
|
||||||
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
|
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
|
||||||
await page.locator('.ge-layers-grab').click();
|
|
||||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
|
|
||||||
await page.locator('#ge-undo').click();
|
await page.locator('#ge-undo').click();
|
||||||
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
|
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
|
||||||
.toEqual(beforeLayer.masks[0].offset);
|
.toEqual(beforeLayer.masks[0].offset);
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
test('static editor modules survive a cache upgrade and offline reopen', async ({ browser }, testInfo) => {
|
|
||||||
const context = await browser.newContext({
|
|
||||||
baseURL: testInfo.project.use.baseURL,
|
|
||||||
storageState: path.join(process.env.PHOTO_EDITOR_E2E_DATA_DIR, 'browser-auth.json'),
|
|
||||||
serviceWorkers: 'allow',
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
const pickerUrl = '/static/js/colorPicker.js?v=20260910eyedropper1';
|
|
||||||
const cssUrl = '/static/css/13-image-editor.css?v=20260929csssplit';
|
|
||||||
const editorUrl = '/static/js/galleryEditor.js?v=20260909movepicklayer1';
|
|
||||||
try {
|
|
||||||
// The app's worker intentionally controls /static/, not the SPA root.
|
|
||||||
await page.goto('/static/js/panels.js?v=20260909movepicklayer1');
|
|
||||||
const installed = await page.evaluate(async ({ pickerUrl, cssUrl }) => {
|
|
||||||
const previous = await caches.open('odysseus-v1113-pr40-editor-precache');
|
|
||||||
await previous.put(pickerUrl, new Response('stale picker'));
|
|
||||||
await previous.put(cssUrl, new Response('stale css'));
|
|
||||||
const registration = await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
|
|
||||||
await navigator.serviceWorker.ready;
|
|
||||||
if (!navigator.serviceWorker.controller) {
|
|
||||||
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
|
||||||
}
|
|
||||||
return { scope: new URL(registration.scope).pathname, root: !!(await navigator.serviceWorker.getRegistration('/')) };
|
|
||||||
}, { pickerUrl, cssUrl });
|
|
||||||
expect(installed).toEqual({ scope: '/static/', root: false });
|
|
||||||
await expect.poll(() => page.evaluate(async () => (await caches.keys()).includes('odysseus-v1113-pr40-editor-precache'))).toBe(false);
|
|
||||||
for (const url of [pickerUrl, cssUrl, editorUrl, '/static/js/platform.js', '/static/js/modelSort.js']) {
|
|
||||||
expect(await page.evaluate(async value => !!(await caches.match(value)), url)).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate stale entries in the active version, then verify network-first
|
|
||||||
// refresh updates both JS and CSS before testing the offline fallback.
|
|
||||||
const currentCache = await page.evaluate(async () => (await caches.keys()).find(name => name.startsWith('odysseus-')));
|
|
||||||
expect(currentCache).toMatch(/^odysseus-v\d+-pr40-editor-precache$/);
|
|
||||||
expect(currentCache).not.toBe('odysseus-v1113-pr40-editor-precache');
|
|
||||||
await page.evaluate(async ({ currentCache, pickerUrl, cssUrl }) => {
|
|
||||||
const cache = await caches.open(currentCache);
|
|
||||||
await cache.put(pickerUrl, new Response('stale picker'));
|
|
||||||
await cache.put(cssUrl, new Response('stale css'));
|
|
||||||
for (const url of [pickerUrl, cssUrl]) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error(`Network refresh failed: ${url} ${response.status}`);
|
|
||||||
}
|
|
||||||
}, { currentCache, pickerUrl, cssUrl });
|
|
||||||
for (const url of [pickerUrl, cssUrl]) {
|
|
||||||
await expect.poll(() => page.evaluate(async value => {
|
|
||||||
const response = await caches.match(value);
|
|
||||||
return response ? !(await response.text()).startsWith('stale ') : false;
|
|
||||||
}, url)).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Origin is the only Vary header the offline fallback may ignore. An entry
|
|
||||||
// that varies on anything else (as a per-user response would) must miss.
|
|
||||||
const varyProbeUrl = '/static/js/e2e-vary-probe.js';
|
|
||||||
await page.evaluate(async ({ currentCache, varyProbeUrl }) => {
|
|
||||||
const cache = await caches.open(currentCache);
|
|
||||||
await cache.put(
|
|
||||||
new Request(varyProbeUrl, { headers: { 'X-Probe-Owner': 'another-user' } }),
|
|
||||||
new Response('export default "private";', { headers: { 'Content-Type': 'text/javascript', Vary: 'Origin, X-Probe-Owner' } }),
|
|
||||||
);
|
|
||||||
}, { currentCache, varyProbeUrl });
|
|
||||||
|
|
||||||
await context.setOffline(true);
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
||||||
const offline = await context.newPage();
|
|
||||||
await offline.goto('/static/js/panels.js?v=20260909movepicklayer1');
|
|
||||||
expect(await offline.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
|
||||||
const result = await offline.evaluate(async ({ pickerUrl, editorUrl, cssUrl, varyProbeUrl }) => {
|
|
||||||
const editor = await import(editorUrl);
|
|
||||||
const picker = await import(pickerUrl);
|
|
||||||
const css = await fetch(cssUrl);
|
|
||||||
const privateProbe = await import(varyProbeUrl).then(() => 'served', () => 'rejected');
|
|
||||||
return {
|
|
||||||
editor: typeof editor.openEditor,
|
|
||||||
picker: ['getRecentColors', 'recordRecentColor', 'suggestColorsFor'].every(key => typeof picker[key] === 'function'),
|
|
||||||
cssOk: css.ok,
|
|
||||||
privateProbe,
|
|
||||||
};
|
|
||||||
}, { pickerUrl, editorUrl, cssUrl, varyProbeUrl });
|
|
||||||
expect(result).toEqual({ editor: 'function', picker: true, cssOk: true, privateProbe: 'rejected' });
|
|
||||||
await offline.close();
|
|
||||||
}
|
|
||||||
} finally { await context.close(); }
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const { openBlankEditor } = require('./helpers.js');
|
|
||||||
|
|
||||||
test('channel and path panels display hostile layer metadata as text', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 64, height: 64 });
|
|
||||||
const result = await page.evaluate(async () => {
|
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
|
||||||
const { createColorPanels } = await import('/static/js/editor/panels/panels.js');
|
|
||||||
const { createStudioPanels } = await import('/static/js/editor/panels/studio-panels.js');
|
|
||||||
const attack = '<img src=x onerror="window.__panelXss=true">';
|
|
||||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
|
||||||
layer.name = attack;
|
|
||||||
layer.kind = 'shape';
|
|
||||||
layer.shape = { type: attack };
|
|
||||||
const colors = createColorPanels({ state });
|
|
||||||
const studio = createStudioPanels({ state });
|
|
||||||
document.body.append(colors.channels, studio.paths);
|
|
||||||
colors.refresh();
|
|
||||||
studio.refresh();
|
|
||||||
return {
|
|
||||||
attack,
|
|
||||||
channel: colors.channels.querySelector('.ge-channel-name').textContent,
|
|
||||||
path: studio.paths.querySelector('.ge-channel-name').textContent,
|
|
||||||
images: colors.channels.querySelectorAll('img').length + studio.paths.querySelectorAll('img').length,
|
|
||||||
executed: !!window.__panelXss,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
expect(result.channel).toBe(result.attack);
|
|
||||||
expect(result.path).toBe(`${result.attack} · ${result.attack}`);
|
|
||||||
expect(result.images).toBe(0);
|
|
||||||
expect(result.executed).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown effect types cannot inject markup into the layer panel', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 64, height: 64 });
|
|
||||||
const attack = '<img src=x onerror="window.__effectXss=true">';
|
|
||||||
const id = await page.evaluate(async value => {
|
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
|
||||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
|
||||||
layer.effects = [{ id: 'effect-test', type: value, visible: true, params: {} }];
|
|
||||||
return layer.id;
|
|
||||||
}, attack);
|
|
||||||
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-vis`).first().click();
|
|
||||||
const list = page.locator('#ge-layers-list');
|
|
||||||
await expect(list).toContainText('Gaussian Blur');
|
|
||||||
await expect(list).not.toContainText(attack);
|
|
||||||
await expect(list.locator('img')).toHaveCount(0);
|
|
||||||
expect(await page.evaluate(() => !!window.__effectXss)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('saved brush preset names remain literal text in the flyout', async ({ page }) => {
|
|
||||||
const attack = '<img src=x onerror="window.__brushXss=true">';
|
|
||||||
await page.addInitScript(value => {
|
|
||||||
localStorage.setItem('odysseus.editor.brush-presets.v1', JSON.stringify([
|
|
||||||
{ id: 'hostile-preset', name: value, size: 8, opacity: 100, flow: 100, softness: 20 },
|
|
||||||
]));
|
|
||||||
}, attack);
|
|
||||||
await openBlankEditor(page, { width: 64, height: 64 });
|
|
||||||
const preset = page.locator('.ge-tool-flyout-preset[data-brush-preset="hostile-preset"]');
|
|
||||||
await expect(preset.locator('.ge-tool-flyout-label')).toHaveText(attack);
|
|
||||||
await expect(preset.locator('img')).toHaveCount(0);
|
|
||||||
expect(await page.evaluate(() => !!window.__brushXss)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('imported adjustment parameters cannot inject markup into the adjustment popup', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 64, height: 64 });
|
|
||||||
const attack = '"><img src=x onerror="window.__adjXss=true">';
|
|
||||||
const cases = [
|
|
||||||
{ type: 'color-lookup', params: { cube: { size: attack, data: [0, 0, 0] }, strength: attack }, text: '.ge-adj-lut-name', expected: 'Loaded LUT · 0³' },
|
|
||||||
{ type: 'gradient-map', params: { shadows: attack, highlights: attack } },
|
|
||||||
{ type: 'levels', params: { inBlack: attack, outWhite: attack } },
|
|
||||||
];
|
|
||||||
for (const item of cases) {
|
|
||||||
await page.locator('#ge-add-fx').click();
|
|
||||||
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${item.type}"]`).click();
|
|
||||||
await page.locator('.ge-adj-popup [data-adj-action="ok"]').click();
|
|
||||||
await expect(page.locator('.ge-adj-popup')).toHaveCount(0);
|
|
||||||
// Project files and drafts reach the editor through the codec's
|
|
||||||
// normalizer, which validates the type but not every parameter.
|
|
||||||
await page.evaluate(async ({ type, params }) => {
|
|
||||||
const { state } = await import('/static/js/editor/state.js');
|
|
||||||
const { normalizeAdjustmentData } = await import('/static/js/editor/adjustment-layer.js');
|
|
||||||
const layer = state.layers.find(entry => entry.id === state.activeLayerId);
|
|
||||||
layer.adjustment = normalizeAdjustmentData({ type, params });
|
|
||||||
}, item);
|
|
||||||
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
|
|
||||||
const popup = page.locator('.ge-adj-popup');
|
|
||||||
await expect(popup).toBeVisible();
|
|
||||||
await expect(popup.locator('img')).toHaveCount(0);
|
|
||||||
if (item.text) await expect(popup.locator(item.text)).toHaveText(item.expected);
|
|
||||||
expect(await popup.evaluate(node => node.innerHTML.includes('onerror'))).toBe(false);
|
|
||||||
await popup.locator('[data-adj-action="cancel"]').click();
|
|
||||||
}
|
|
||||||
expect(await page.evaluate(() => !!window.__adjXss)).toBe(false);
|
|
||||||
});
|
|
||||||
@@ -38,45 +38,3 @@ test('mobile footer docks flush between the canvas and the layers sheet', async
|
|||||||
expect(g.footerLeft).toBeLessThanOrEqual(1);
|
expect(g.footerLeft).toBeLessThanOrEqual(1);
|
||||||
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
|
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('phone footer controls for zoom, setup, and AI settings are accessible via normal clicks', async ({ page }) => {
|
|
||||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer controls');
|
|
||||||
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
|
|
||||||
|
|
||||||
const footer = page.locator('.ge-editor-footer');
|
|
||||||
await expect(footer).toBeVisible();
|
|
||||||
|
|
||||||
const setupBtn = page.locator('#ge-setup-btn');
|
|
||||||
const aiBtn = page.locator('#ge-ai-settings-btn');
|
|
||||||
const zoomIn = page.locator('#ge-zoom-in');
|
|
||||||
const zoomOut = page.locator('#ge-zoom-out');
|
|
||||||
const zoomFit = page.locator('#ge-zoom-100');
|
|
||||||
|
|
||||||
await expect(setupBtn).toBeVisible();
|
|
||||||
await expect(aiBtn).toBeVisible();
|
|
||||||
await expect(zoomIn).toBeVisible();
|
|
||||||
await expect(zoomOut).toBeVisible();
|
|
||||||
await expect(zoomFit).toBeVisible();
|
|
||||||
|
|
||||||
// Test zoom controls via normal clicks
|
|
||||||
const zoomLabel = page.locator('.ge-zoom-label');
|
|
||||||
const initialZoom = await zoomLabel.textContent();
|
|
||||||
await zoomIn.click();
|
|
||||||
await expect(zoomLabel).not.toHaveText(initialZoom);
|
|
||||||
await zoomFit.click();
|
|
||||||
await expect(zoomLabel).toHaveText(/%/);
|
|
||||||
|
|
||||||
// Test Setup modal accessibility via normal click
|
|
||||||
await setupBtn.click();
|
|
||||||
const preflight = page.locator('.ge-preflight');
|
|
||||||
await expect(preflight).toBeVisible();
|
|
||||||
await page.locator('.ge-preflight-start').click();
|
|
||||||
await expect(preflight).toBeHidden();
|
|
||||||
|
|
||||||
// Test AI Settings modal accessibility via normal click
|
|
||||||
await aiBtn.click();
|
|
||||||
const aiSettings = page.locator('.ge-ai-settings');
|
|
||||||
await expect(aiSettings).toBeVisible();
|
|
||||||
await page.locator('.ge-ai-settings-done').click();
|
|
||||||
await expect(aiSettings).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const { openBlankEditor } = require('./helpers.js');
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
|
||||||
|
|
||||||
test('setup checklist remains usable above the mobile layers sheet', async ({ page }) => {
|
|
||||||
await openBlankEditor(page);
|
|
||||||
await page.locator('#ge-setup-btn').click();
|
|
||||||
const checklist = page.locator('.ge-preflight');
|
|
||||||
const start = checklist.locator('[data-preflight-start]');
|
|
||||||
await expect(checklist).toBeVisible();
|
|
||||||
await start.scrollIntoViewIfNeeded();
|
|
||||||
await expect(start).toHaveCSS('white-space', 'nowrap');
|
|
||||||
expect(await start.evaluate(button => button.scrollWidth <= button.clientWidth)).toBe(true);
|
|
||||||
await start.click();
|
|
||||||
await expect(checklist).toBeHidden();
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
|
const { editorState, openBlankEditor } = require('./helpers.js');
|
||||||
|
|
||||||
async function currentPng(page) {
|
async function currentPng(page) {
|
||||||
return page.evaluate(async () => {
|
return page.evaluate(async () => {
|
||||||
@@ -46,7 +46,6 @@ test('mixed-corrupt project recovers valid layers and remains undoable', async (
|
|||||||
|
|
||||||
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
|
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
|
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
|
||||||
await waitForDraft(page);
|
|
||||||
const before = await editorState(page);
|
const before = await editorState(page);
|
||||||
await loadProject(page, {
|
await loadProject(page, {
|
||||||
type: 'odysseus-gallery-editor-project',
|
type: 'odysseus-gallery-editor-project',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { dragOnCanvas, editorState, openBlankEditor, selectTool } = require('./helpers.js');
|
const { selectTool } = require('./helpers.js');
|
||||||
|
const { openBlankEditor, editorState } = require('./helpers.js');
|
||||||
|
|
||||||
test('paint tools require confirmation before rasterizing text layers, preserving text on cancel', async ({ page }) => {
|
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
|
||||||
await openBlankEditor(page);
|
await openBlankEditor(page);
|
||||||
await selectTool(page, 'text');
|
await selectTool(page, 'text');
|
||||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||||
@@ -10,39 +11,19 @@ test('paint tools require confirmation before rasterizing text layers, preservin
|
|||||||
await text.fill('Keep editable');
|
await text.fill('Keep editable');
|
||||||
await text.press('Control+Enter');
|
await text.press('Control+Enter');
|
||||||
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
|
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
|
||||||
expect(retained).toBeDefined();
|
|
||||||
const kind = async () => (await editorState(page)).layers.find(layer => layer.id === retained.id)?.kind;
|
|
||||||
|
|
||||||
// Tool selection alone does not prompt and does not rasterize
|
|
||||||
for (const tool of ['brush', 'eraser']) {
|
|
||||||
await selectTool(page, tool);
|
|
||||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
|
||||||
expect(await kind()).toBe('text');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempting to draw triggers confirmation
|
|
||||||
await selectTool(page, 'brush');
|
await selectTool(page, 'brush');
|
||||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
|
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
|
||||||
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Cancelling keeps text editable with no changes
|
|
||||||
await page.locator('#styled-confirm-cancel').click();
|
|
||||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||||
expect(await kind()).toBe('text');
|
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
|
||||||
|
await selectTool(page, 'brush');
|
||||||
// Second stroke prompt confirmed -> rasterizes layer
|
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
|
||||||
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
|
await page.keyboard.press('Enter');
|
||||||
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
|
|
||||||
await page.locator('#styled-confirm-ok').click();
|
|
||||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
||||||
expect(await kind()).toBe('raster');
|
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
|
||||||
|
|
||||||
// Subsequent strokes on the raster layer draw immediately without prompting
|
|
||||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.35, y: 0.3 });
|
|
||||||
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
|
|
||||||
|
|
||||||
// Undo restores editable text
|
|
||||||
await page.keyboard.press('Control+z');
|
await page.keyboard.press('Control+z');
|
||||||
await page.keyboard.press('Control+z');
|
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
|
||||||
expect(await kind()).toBe('text');
|
await selectTool(page, 'eraser');
|
||||||
|
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,15 +106,12 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
|
|||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
await page.locator('#ge-selection-menu-btn').click();
|
await page.locator('#ge-selection-menu-btn').click();
|
||||||
await page.locator('[data-selection-action="transform"]').click();
|
await page.locator('[data-selection-action="transform"]').click();
|
||||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
|
||||||
const mobileSection = await page.locator('#ge-transform-section').boundingBox();
|
const mobileSection = await page.locator('#ge-transform-section').boundingBox();
|
||||||
expect(mobileSection.x).toBeGreaterThanOrEqual(0);
|
expect(mobileSection.x).toBeGreaterThanOrEqual(0);
|
||||||
expect(mobileSection.y).toBeGreaterThanOrEqual(0);
|
expect(mobileSection.y).toBeGreaterThanOrEqual(0);
|
||||||
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
|
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
|
||||||
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
|
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
|
||||||
await page.locator('#ge-transform-cancel-btn').click();
|
await page.locator('#ge-transform-cancel-btn').click();
|
||||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformActive)).toBe(false);
|
|
||||||
expect((await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
|
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const { openBlankEditor, selectTool } = require('./helpers.js');
|
|
||||||
|
|
||||||
// Editors opened wider than 700px move the tool controls into the options
|
|
||||||
// strip. At tablet widths (701-820px) that strip must stay visible, or every
|
|
||||||
// tool control is stranded in a hidden container.
|
|
||||||
for (const width of [768, 820]) {
|
|
||||||
test(`tool controls stay reachable at a ${width}px tablet width`, async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width, height: 1024 });
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, `Tablet ${width}`);
|
|
||||||
const inViewport = locator => locator.evaluate(el => {
|
|
||||||
const box = el.getBoundingClientRect();
|
|
||||||
return box.width > 0 && box.left >= 0 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
await selectTool(page, 'brush');
|
|
||||||
await expect(page.locator('#ge-options-bar')).toBeVisible();
|
|
||||||
await expect(page.locator('#ge-options-bar .ge-size-slider')).toBeVisible();
|
|
||||||
expect(await inViewport(page.locator('#ge-options-bar .ge-size-slider'))).toBe(true);
|
|
||||||
|
|
||||||
await selectTool(page, 'transform');
|
|
||||||
await expect(page.locator('#ge-transform-w')).toBeVisible();
|
|
||||||
await page.locator('#ge-transform-w').fill('200');
|
|
||||||
await page.locator('#ge-transform-w').dispatchEvent('input');
|
|
||||||
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformPendingW)).toBe(200);
|
|
||||||
await page.locator('#ge-transform-cancel-btn').click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('responsive transition across 700px breakpoint preserves dock/sheet layout and state', async ({ page }) => {
|
|
||||||
// Desktop -> Phone transition
|
|
||||||
await page.setViewportSize({ width: 1024, height: 768 });
|
|
||||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Responsive transitions');
|
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
|
|
||||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
|
|
||||||
|
|
||||||
// Resize across 700px to phone
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/ge-right-panel--dock/);
|
|
||||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeHidden();
|
|
||||||
|
|
||||||
// Resize back to desktop
|
|
||||||
await page.setViewportSize({ width: 1024, height: 768 });
|
|
||||||
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
|
|
||||||
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
const { selectTool } = require('./helpers.js');
|
const { selectTool } = require('./helpers.js');
|
||||||
const { dragOnCanvas, editorState, openBlankEditor, openOptionsMore, reopenDraft, waitForDraft } = require('./helpers.js');
|
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
|
||||||
|
|
||||||
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
|
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
|
||||||
await openBlankEditor(page);
|
await openBlankEditor(page);
|
||||||
await selectTool(page, 'text');
|
await selectTool(page, 'text');
|
||||||
await openOptionsMore(page, 'text');
|
|
||||||
await page.locator('#ge-text-frame-width').fill('260');
|
await page.locator('#ge-text-frame-width').fill('260');
|
||||||
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
const canvas = await page.locator('.ge-main-canvas').boundingBox();
|
||||||
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
|
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
|
||||||
@@ -15,13 +14,11 @@ test('text edits directly on canvas and remains retained after reopen', async ({
|
|||||||
await editor.press('Control+Enter');
|
await editor.press('Control+Enter');
|
||||||
await expect(editor).toHaveCount(0);
|
await expect(editor).toHaveCount(0);
|
||||||
|
|
||||||
await openOptionsMore(page, 'text');
|
|
||||||
await page.locator('#ge-text-letter-spacing').fill('3.5');
|
await page.locator('#ge-text-letter-spacing').fill('3.5');
|
||||||
await page.locator('#ge-text-line-height').fill('1.4');
|
await page.locator('#ge-text-line-height').fill('1.4');
|
||||||
await page.locator('#ge-text-font').selectOption('Georgia');
|
await page.locator('#ge-text-font').selectOption('Georgia');
|
||||||
await page.locator('.ge-text-auto-width-option').click();
|
await page.locator('.ge-text-auto-width-option').click();
|
||||||
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
|
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
|
||||||
await openOptionsMore(page, 'text');
|
|
||||||
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
|
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
|
||||||
await page.locator('#ge-text-frame-height').fill('180');
|
await page.locator('#ge-text-frame-height').fill('180');
|
||||||
await page.locator('#ge-text-vertical-align').selectOption('bottom');
|
await page.locator('#ge-text-vertical-align').selectOption('bottom');
|
||||||
@@ -50,13 +47,8 @@ test('text paragraph controls remain usable in a narrow phone viewport', async (
|
|||||||
const section = page.locator('#ge-text-section');
|
const section = page.locator('#ge-text-section');
|
||||||
await expect(section).toBeVisible();
|
await expect(section).toBeVisible();
|
||||||
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||||
await openOptionsMore(page, 'text');
|
|
||||||
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
|
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
|
||||||
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
|
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
|
||||||
expect(await page.locator('#ge-text-more').evaluate(el => {
|
|
||||||
const box = el.getBoundingClientRect();
|
|
||||||
return box.left >= 0 && box.right <= innerWidth + 1;
|
|
||||||
})).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
|
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
|
||||||
@@ -79,7 +71,6 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
|
|||||||
let current = await editorState(page);
|
let current = await editorState(page);
|
||||||
const shapes = current.layers.filter(layer => layer.kind === 'shape');
|
const shapes = current.layers.filter(layer => layer.kind === 'shape');
|
||||||
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
|
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
|
||||||
await openOptionsMore(page, 'shape');
|
|
||||||
await page.locator('#ge-shape-sides').fill('7');
|
await page.locator('#ge-shape-sides').fill('7');
|
||||||
await page.locator('#ge-shape-stroke-width').fill('6');
|
await page.locator('#ge-shape-stroke-width').fill('6');
|
||||||
await page.locator('#ge-shape-radius').fill('14');
|
await page.locator('#ge-shape-radius').fill('14');
|
||||||
@@ -105,7 +96,6 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
|
|||||||
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
|
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
|
||||||
await openBlankEditor(page);
|
await openBlankEditor(page);
|
||||||
await selectTool(page, 'shape');
|
await selectTool(page, 'shape');
|
||||||
await openOptionsMore(page, 'shape');
|
|
||||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
|
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
|
||||||
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
|
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
|
||||||
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
|
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
|
||||||
@@ -120,7 +110,6 @@ test('gradient shape fill remains editable after reopening the project', async (
|
|||||||
angle.value = '35';
|
angle.value = '35';
|
||||||
angle.dispatchEvent(new Event('input', { bubbles: true }));
|
angle.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
});
|
});
|
||||||
await openOptionsMore(page, 'shape');
|
|
||||||
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
|
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
|
||||||
|
|
||||||
const before = await editorState(page);
|
const before = await editorState(page);
|
||||||
@@ -154,7 +143,6 @@ test('shape gradients retain added stops through editing and reopen', async ({ p
|
|||||||
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
|
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
|
||||||
await selectTool(page, 'shape');
|
await selectTool(page, 'shape');
|
||||||
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
|
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
|
||||||
await openOptionsMore(page, 'shape');
|
|
||||||
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
|
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
|
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user