mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 18:52:21 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf9ba27f6 | ||
|
|
8450793fe6 | ||
|
|
c4fdd7ad5b | ||
|
|
6fced41220 | ||
|
|
6f2b58c896 | ||
|
|
39303f19cd | ||
|
|
5353241743 | ||
|
|
285d05e72e | ||
|
|
dc0fbbf7aa | ||
|
|
12b9c5f2ad | ||
|
|
cb26ccd9a7 | ||
|
|
23a480571a | ||
|
|
6a43cf23ea | ||
|
|
64b457dc0a | ||
|
|
ef3467e8d4 | ||
|
|
72e8520fa3 | ||
|
|
9276db9c11 | ||
|
|
a797cee0b3 | ||
|
|
76061498c8 | ||
|
|
04f8546d9d | ||
|
|
9835c1a7f9 | ||
|
|
239f09cbc4 | ||
|
|
a1a7f8fc9a | ||
|
|
87193d23f5 | ||
|
|
516f046fef | ||
|
|
48c3a7fc36 | ||
|
|
70fc58225c | ||
|
|
9efedc8b07 | ||
|
|
1d251ef867 | ||
|
|
bf5bf57bc1 | ||
|
|
2019a97339 | ||
|
|
9a1fdb192e | ||
|
|
bebf53be3c | ||
|
|
a8e0ff1c8e | ||
|
|
31685b050a | ||
|
|
03d25943ee | ||
|
|
71e3649985 | ||
|
|
45a57c0c38 | ||
|
|
71f6dc4e5d | ||
|
|
0c8a06a937 | ||
|
|
eea39ce103 | ||
|
|
9a5a15e17f | ||
|
|
f141ad2be5 | ||
|
|
a6e9294394 | ||
|
|
cfbe4aeca4 | ||
|
|
6f0679bb25 | ||
|
|
93c93bf076 | ||
|
|
f0f7464dfa | ||
|
|
1e57f774d3 | ||
|
|
2b310010cf | ||
|
|
b88154ecf9 | ||
|
|
e7796f71d3 | ||
|
|
412789051e | ||
|
|
b8adc4eb60 | ||
|
|
2246215b79 | ||
|
|
06fee0a3d8 | ||
|
|
f3813c261c | ||
|
|
e7a376b161 | ||
|
|
fec2bb11d6 | ||
|
|
c811f76d44 | ||
|
|
02d28c06d0 | ||
|
|
00ecf1c74e | ||
|
|
63feb227bb | ||
|
|
12fcddefab | ||
|
|
b46aa02ce1 | ||
|
|
352ee706f3 | ||
|
|
98b359cd4c | ||
|
|
2a2256af0e | ||
|
|
4f131d7c21 | ||
|
|
945e747eb4 | ||
|
|
61051d28f0 | ||
|
|
d87ae2a7f5 | ||
|
|
4fe8fb28e5 | ||
|
|
84e232e7ee | ||
|
|
2cace27723 | ||
|
|
508c7b3d9d | ||
|
|
5b8a72931d | ||
|
|
2e7edf908d | ||
|
|
b80f9afb3e | ||
|
|
533d48ad8d | ||
|
|
d5bc7fb70e | ||
|
|
f510b1973a | ||
|
|
1339efa856 | ||
|
|
6b284d2bc5 | ||
|
|
8acaec20ee | ||
|
|
5f18bcf6f8 | ||
|
|
18b6140511 | ||
|
|
eeb1107f7b | ||
|
|
4795c8d462 | ||
|
|
5c738a5a8e | ||
|
|
0ff5d47a8c | ||
|
|
f780f1dce9 | ||
|
|
1c5853003e | ||
|
|
0f8b137713 | ||
|
|
2c5d5005ba | ||
|
|
901b3ff42d | ||
|
|
c207ed1ed8 | ||
|
|
8b3a866899 | ||
|
|
4d8d363681 | ||
|
|
a7e8a6ef2e | ||
|
|
12631da4d3 | ||
|
|
927985f847 | ||
|
|
2046ce893f | ||
|
|
35ef91b25c | ||
|
|
91922d854c | ||
|
|
641752387b | ||
|
|
b3f86a0724 | ||
|
|
94dc74e500 | ||
|
|
fd27e9a429 | ||
|
|
bf3c000d1c | ||
|
|
8cd96aad46 | ||
|
|
6123b70b5c | ||
|
|
bfa514f6e8 | ||
|
|
1ff2fd0054 | ||
|
|
15c4cef36a | ||
|
|
84cdd866c5 | ||
|
|
dbfc96b8f3 |
@@ -105,6 +105,17 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# trusted single-user setups that accept injected content reaching side-effect tools.
|
||||
# ODYSSEUS_TOOL_APPROVAL_GATE=1
|
||||
|
||||
# Comma-separated list of exact qualified MCP read and write tool names
|
||||
# on host-local servers. Only explicitly configured stdio MCP servers qualify.
|
||||
# Declarations do not override built-in tool effects. Declared reads remain
|
||||
# untrusted (EXTERNAL_UNTRUSTED) to prevent prompt injections from reaching shell
|
||||
# or network tools, but allow continuation to declared non-destructive write tools
|
||||
# on the exact same stdio server without repeated approvals. Stdio transport does
|
||||
# not guarantee network isolation. Authorization must still originate from the
|
||||
# trusted request. Automatic same-store writes carry residual prompt-injection risk.
|
||||
# ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__notes__search_notes,mcp__notes__read_note
|
||||
# ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__notes__create_note,mcp__notes__update_note
|
||||
|
||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
||||
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||
# force it on, or false to force it off while you still serve plain HTTP.
|
||||
|
||||
@@ -179,6 +179,7 @@ jobs:
|
||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
python scripts/check_ca_trust.py --python
|
||||
uv pip install --system -r requirements.txt
|
||||
uv pip install --system "playwright==1.62.0"
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
|
||||
+2
-1
@@ -66,10 +66,11 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
|
||||
|
||||
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
||||
|
||||
Two exemptions apply even when the gate is on, both deliberate:
|
||||
Three exemptions apply even when the gate is on, all deliberate:
|
||||
|
||||
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
|
||||
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
|
||||
- Narrow continuation for local MCP tools declared via `ODYSSEUS_LOCAL_MCP_READ_TOOLS` and `ODYSSEUS_LOCAL_MCP_WRITE_TOOLS`: Only explicitly configured stdio MCP servers qualify; remote HTTP and SSE servers fail closed. Declarations never override built-in tool effects, so built-in email, browser, memory, image generation, and other built-in servers cannot receive continuation exceptions. Declared read tools remain untrusted (`EXTERNAL_UNTRUSTED`) to ensure that prompt injections in local data cannot reach shell, network, or unrelated tools without approval. Stdio transport does not guarantee network isolation, as local processes may still reach the host network. However, declared non-destructive write tools on the exact same stdio MCP server are permitted to continue without an approval prompt if the only untrusted context in the run was produced by declared local reads on that same server, without any explicit error or external taint. Genuinely external content (web search, web fetch, emails, external message context) blocks these writes. Authorization must still originate from the trusted request: request authority must permit the tool and retain the sealed backend resource grant. The residual same-store write risk (the agent writing to the same local store after reading a local prompt injection) remains subject to the user's initial request authority.
|
||||
|
||||
## Security Headers
|
||||
|
||||
|
||||
@@ -568,6 +568,7 @@ class ModelEndpoint(TimestampMixin, Base):
|
||||
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
|
||||
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
|
||||
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
|
||||
cookbook_serve_session_id = Column(String, nullable=True) # Current Cookbook serve identity
|
||||
model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
|
||||
# auto = classify by URL; local = self-hosted server; api/proxy = external
|
||||
# OpenAI-compatible API even when reachable through a private/tailnet IP.
|
||||
@@ -1378,6 +1379,26 @@ def _migrate_add_pinned_models_column():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_add_cookbook_serve_session_id_column():
|
||||
"""Bind Cookbook cleanup to the serve session that owns the endpoint."""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
with engine.begin() as conn:
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspect(conn).get_columns("model_endpoints")
|
||||
}
|
||||
|
||||
if "cookbook_serve_session_id" not in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE model_endpoints "
|
||||
"ADD COLUMN cookbook_serve_session_id VARCHAR"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migrate_add_notes_sort_order():
|
||||
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
|
||||
import sqlite3
|
||||
@@ -2390,6 +2411,7 @@ def init_db():
|
||||
_migrate_add_hidden_models_column()
|
||||
_migrate_add_cached_models_column()
|
||||
_migrate_add_pinned_models_column()
|
||||
_migrate_add_cookbook_serve_session_id_column()
|
||||
_migrate_add_notes_sort_order()
|
||||
_migrate_add_model_type_column()
|
||||
_migrate_add_model_endpoint_refresh_columns()
|
||||
|
||||
@@ -73,6 +73,8 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -82,13 +84,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -165,7 +169,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -72,6 +72,8 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -81,13 +83,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -168,7 +172,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
+6
-2
@@ -61,6 +61,8 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -70,13 +72,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -146,7 +150,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -31,21 +31,43 @@ for spec in $SPECS; do
|
||||
name="${spec%%==*}"
|
||||
ver="${spec##*==}"
|
||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||
# sdist URL from the PyPI JSON API instead.
|
||||
url="$(python - "$name" "$ver" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
name, ver = sys.argv[1], sys.argv[2]
|
||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||
for f in data["urls"]:
|
||||
if f["packagetype"] == "sdist":
|
||||
print(f["url"]); break
|
||||
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||
# survive transient TLS / network failures that urllib cannot recover from.
|
||||
metadata="${name}-${ver}.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"https://pypi.org/pypi/${name}/${ver}/json" \
|
||||
-o "$metadata"
|
||||
|
||||
url="$(python - "$metadata" <<'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
for item in data["urls"]:
|
||||
if item["packagetype"] == "sdist":
|
||||
print(item["url"])
|
||||
break
|
||||
else:
|
||||
sys.exit(f"no sdist found for {name}=={ver}")
|
||||
raise SystemExit("no sdist found")
|
||||
PY
|
||||
)"
|
||||
)"
|
||||
rm -f "$metadata"
|
||||
|
||||
echo ">> fetching ${name} ${ver}: ${url}"
|
||||
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"$url" \
|
||||
-o "${name}.tar.gz"
|
||||
tar xzf "${name}.tar.gz"
|
||||
rm -f "${name}.tar.gz"
|
||||
done
|
||||
|
||||
echo ">> patching get_version()"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
|
||||
"prompt": {"type": "string", "description": "Image description prompt"},
|
||||
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
|
||||
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
|
||||
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
if "quality" not in arguments and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
|
||||
# Auto-detect best available image model
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
-r requirements.txt
|
||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||
pytest-xdist[psutil]>=3.8,<4
|
||||
playwright==1.62.0
|
||||
|
||||
@@ -887,6 +887,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Create a new integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = add_integration(body)
|
||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||
|
||||
@@ -895,6 +897,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"""Update an existing integration (admin only)."""
|
||||
_require_admin_user(request)
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
item = update_integration(integration_id, body)
|
||||
if not item:
|
||||
raise HTTPException(404, "Integration not found")
|
||||
|
||||
+16
-16
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
|
||||
return FALLBACK_OWNER
|
||||
|
||||
|
||||
async def _json_object_body(request: Request) -> dict:
|
||||
"""Parse a JSON object body. An unreadable body counts as empty; a body
|
||||
that is valid JSON but not an object (a list, string or number) is a 400."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
|
||||
if not cal:
|
||||
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def save_config(request: Request):
|
||||
"""Legacy single-account endpoint — upserts the first account."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
if not (body.get("url") or "").strip():
|
||||
_save_caldav_accounts(owner, [])
|
||||
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
"""Add a new CalDAV account."""
|
||||
import uuid as _uuid
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
from src.caldav_sync import validate_caldav_url
|
||||
try:
|
||||
url = validate_caldav_url(body.get("url", ""))
|
||||
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
async def update_caldav_account(account_id: str, request: Request):
|
||||
"""Update an existing CalDAV account by id."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
accounts = _get_caldav_accounts(owner)
|
||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
||||
if idx is None:
|
||||
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
test an already-saved account. Falls back to the first saved account
|
||||
when nothing is provided."""
|
||||
owner = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
body = await _json_object_body(request)
|
||||
url = (body.get("url") or "").strip()
|
||||
user = (body.get("username") or "").strip()
|
||||
pw = body.get("password") or ""
|
||||
|
||||
@@ -1276,6 +1276,7 @@ async def build_chat_context(
|
||||
message=_ctx_msg,
|
||||
session=sess,
|
||||
use_web=use_web_val and not skip_web,
|
||||
use_rag=use_rag_val,
|
||||
use_memory=mem_enabled,
|
||||
time_filter=time_filter,
|
||||
preset_system_prompt=preset.system_prompt,
|
||||
@@ -1286,8 +1287,6 @@ async def build_chat_context(
|
||||
incognito=incognito,
|
||||
use_skills=skills_enabled,
|
||||
)
|
||||
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
||||
_preface_kwargs["use_rag"] = use_rag_val
|
||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||
|
||||
# Capture used memories immediately
|
||||
|
||||
+17
-1
@@ -1901,7 +1901,7 @@ def _ts_or_zero(value) -> float:
|
||||
|
||||
|
||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||
"""Recover sess.model from the endpoint's visible cached and pinned models.
|
||||
|
||||
Covers the window between endpoint setup and the first chat send: the
|
||||
picker showed a model in the dropdown but the session record never got
|
||||
@@ -1965,6 +1965,9 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||
except Exception:
|
||||
visible = cached
|
||||
if not is_chatgpt_subscription:
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
visible = _endpoint_enabled_models(ep)
|
||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||
return False
|
||||
if is_chatgpt_subscription:
|
||||
@@ -3923,6 +3926,19 @@ def setup_chat_routes(
|
||||
if ctx.preprocessed.attachment_meta:
|
||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||
|
||||
# Give the page the saved user message's id. Only the reply's id
|
||||
# was sent back, so a message sent in the open page had none and
|
||||
# a later regenerate/edit of it could not cut the chat by id.
|
||||
try:
|
||||
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
|
||||
if getattr(m, "role", None) == "user"), None)
|
||||
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
|
||||
if _last_user_msg is not None else None)
|
||||
if _user_db_id:
|
||||
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||
# PDF → editable markdown) so the editor pane switches to them
|
||||
# before the model starts streaming.
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
|
||||
from core.platform_compat import _ssh_exec_argv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
"""
|
||||
lines = [
|
||||
"import json, os, re, shutil, subprocess, urllib.request",
|
||||
*HF_PARTIAL_BLOB_HELPER.splitlines(),
|
||||
"models = []",
|
||||
"seen = set()",
|
||||
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
|
||||
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" sz, nf, ic = 0, 0, False",
|
||||
" if os.path.isdir(blobs):",
|
||||
" for f in os.scandir(blobs):",
|
||||
" if f.name.endswith('.incomplete'):",
|
||||
" if not is_partial_blob(f.path): continue",
|
||||
" ic = True",
|
||||
" if f.is_file(): nf += 1; sz += f.stat().st_size",
|
||||
" if f.name.endswith('.incomplete'): ic = True",
|
||||
" snap = os.path.join(cache, d, 'snapshots')",
|
||||
" def snapshot_size():",
|
||||
" total, count, incomplete = 0, 0, False",
|
||||
@@ -1487,6 +1491,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
|
||||
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
|
||||
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
|
||||
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
|
||||
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
|
||||
[
|
||||
{
|
||||
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
|
||||
"op": "manual",
|
||||
},
|
||||
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
|
||||
@@ -8,6 +8,20 @@ import re
|
||||
|
||||
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
|
||||
# Shared source embedded in local/remote inventory and status probes. A blank
|
||||
# retry marker beside its completed, nonempty content-addressed blob is stale;
|
||||
# a blank marker alone may be a download that has not received its first byte.
|
||||
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
|
||||
if not path.endswith('.incomplete'):
|
||||
return False
|
||||
try:
|
||||
completed = path[:-len('.incomplete')]
|
||||
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
|
||||
or os.path.getsize(completed) == 0)
|
||||
except OSError:
|
||||
return True
|
||||
"""
|
||||
|
||||
# Probe scripts for the dead-session download check, run as
|
||||
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||
# the probe process's own environment knows nothing about it.
|
||||
HF_CACHE_COMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
|
||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||
"inc=False;"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if ok and not inc else 1)"
|
||||
)
|
||||
|
||||
HF_CACHE_INCOMPLETE_PROBE = (
|
||||
"import os,sys;"
|
||||
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
|
||||
"repo=sys.argv[1];"
|
||||
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||
"blobs=os.path.join(d,'blobs');"
|
||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
|
||||
"sys.exit(0 if inc else 1)"
|
||||
)
|
||||
|
||||
|
||||
+103
-242
@@ -468,175 +468,6 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
task["payload"].pop("hf_token", None)
|
||||
return state
|
||||
|
||||
def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
|
||||
|
||||
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
|
||||
the agent/tool path the same structured signal so it can retry with an
|
||||
adjusted command instead of guessing from raw tmux output.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
tail = text[-6000:]
|
||||
patterns = [
|
||||
(
|
||||
r"No available memory for the cache blocks|Available KV cache memory:.*-",
|
||||
"No GPU memory left for KV cache after loading model.",
|
||||
[
|
||||
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
|
||||
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
|
||||
"GPU ran out of memory during startup or warmup.",
|
||||
[
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
|
||||
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"not divisib|must be divisible|attention heads.*divisible",
|
||||
"Tensor parallel size is incompatible with the model.",
|
||||
[
|
||||
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
|
||||
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
|
||||
"Context length is too large for available GPU memory.",
|
||||
[
|
||||
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"enable-auto-tool-choice requires --tool-call-parser",
|
||||
"Auto tool choice requires an explicit tool call parser.",
|
||||
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
|
||||
),
|
||||
(
|
||||
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
|
||||
"Model requires custom code or newer model support.",
|
||||
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
|
||||
),
|
||||
(
|
||||
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
|
||||
"vLLM/Transformers kernel package mismatch.",
|
||||
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
|
||||
),
|
||||
(
|
||||
r"Address already in use|bind.*address.*in use",
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
|
||||
),
|
||||
(
|
||||
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
|
||||
"vLLM could not find a supported GPU (CUDA or ROCm). "
|
||||
"This machine may have integrated or unsupported graphics only.",
|
||||
[
|
||||
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
|
||||
"vLLM is not installed or not in PATH on this server.",
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
||||
r"Could not load any common_ops library|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
||||
[
|
||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||
),
|
||||
(
|
||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
[
|
||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||
],
|
||||
),
|
||||
# System build deps come BEFORE the generic llama.cpp catch-all
|
||||
# so cmake / build-essential / git missing → a specific OS-package
|
||||
# remediation instead of "install llama-cpp-python[server]" (which
|
||||
# itself fails to compile when cmake is absent).
|
||||
(
|
||||
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
||||
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
||||
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
||||
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^git: command not found",
|
||||
"git is required to clone the llama.cpp source tree.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
||||
"llama.cpp / llama-cpp-python dependencies are missing.",
|
||||
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
|
||||
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
|
||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||
),
|
||||
(
|
||||
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||
),
|
||||
(
|
||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||
"Model access is gated or unauthorized.",
|
||||
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
|
||||
),
|
||||
]
|
||||
for pattern, message, suggestions in patterns:
|
||||
if re.search(pattern, tail, re.I):
|
||||
return {"message": message, "suggestions": suggestions}
|
||||
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
|
||||
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
|
||||
):
|
||||
return {
|
||||
"message": "Python traceback detected during serve startup.",
|
||||
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
|
||||
}
|
||||
return None
|
||||
|
||||
def _state_for_client(state):
|
||||
"""Return cookbook state without raw secrets for browser clients."""
|
||||
_strip_task_secrets(state)
|
||||
@@ -1577,10 +1408,15 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
return {"models": models, "host": host or "local"}
|
||||
|
||||
def _auto_register_image_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
||||
def _auto_register_image_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
from src.settings import load_settings, save_settings
|
||||
|
||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||
@@ -1608,9 +1444,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "image"
|
||||
existing.name = display_name
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "manual"
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
@@ -1634,6 +1474,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
model_refresh_mode="manual",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
@@ -1779,43 +1620,71 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# let the probe layer mark it offline if nothing's listening.
|
||||
logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}")
|
||||
return
|
||||
# Non-zero exit — drop the endpoint.
|
||||
# A nonzero exit may delete only the endpoint owned by this serve.
|
||||
try:
|
||||
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
|
||||
from core.constants import internal_api_base as _internal_api_base
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
from urllib.parse import quote
|
||||
import httpx
|
||||
|
||||
db = _SL()
|
||||
try:
|
||||
ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
|
||||
if ep:
|
||||
# A scheduled serve can leave old non-zero exit markers
|
||||
# in tmux scrollback while the current OpenAI endpoint is
|
||||
# actually alive. Verify reachability before deleting the
|
||||
# endpoint row; otherwise chats fall back even though the
|
||||
# served model is ready.
|
||||
try:
|
||||
probe_url = ep.base_url.rstrip("/") + "/models"
|
||||
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
||||
if 200 <= getattr(resp, "status", 0) < 300:
|
||||
logger.info(
|
||||
f"crash-watchdog: serve {session_id} has exit marker {exit_code} "
|
||||
f"but endpoint {ep.id} is reachable; leaving it registered"
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if not ep or ep.cookbook_serve_session_id != session_id:
|
||||
logger.info(
|
||||
f"crash-watchdog: dropping endpoint {endpoint_id} "
|
||||
f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}"
|
||||
"crash-watchdog: endpoint %s no longer owned by %s",
|
||||
endpoint_id, session_id,
|
||||
)
|
||||
db.delete(ep)
|
||||
db.commit()
|
||||
return
|
||||
probe_url = ep.base_url.rstrip("/") + "/models"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Preserve an endpoint that is already healthy, including
|
||||
# after an old runner emits a stale failure marker.
|
||||
try:
|
||||
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
||||
if 200 <= getattr(resp, "status", 0) < 300:
|
||||
logger.info(
|
||||
"crash-watchdog: endpoint %s is reachable; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.delete(
|
||||
f"{_internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||
params={"expected_cookbook_serve_session_id": session_id},
|
||||
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"crash-watchdog: deleted endpoint %s after serve %s exited %s",
|
||||
endpoint_id, session_id, exit_code,
|
||||
)
|
||||
elif response.status_code in (404, 409):
|
||||
logger.info(
|
||||
"crash-watchdog: endpoint %s absent or reassigned; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"crash-watchdog: endpoint %s deletion returned HTTP %s",
|
||||
endpoint_id, response.status_code,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}")
|
||||
logger.warning("crash-watchdog: endpoint cleanup failed: %r", e)
|
||||
return
|
||||
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
|
||||
|
||||
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
||||
def _auto_register_llm_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||
model picker without a manual /setup step — the text-model sibling of
|
||||
_auto_register_image_endpoint.
|
||||
@@ -1832,6 +1701,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
)
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
|
||||
# Port: ordered fallbacks so we match whatever the user actually
|
||||
# asked for, not a hardcoded default:
|
||||
@@ -1893,8 +1763,12 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# deployment alias from /v1/models, and a stale server can answer on the
|
||||
# same port while the new launch failed. Keeping the requested model id
|
||||
# pinned makes the picker reflect the actual launch intent.
|
||||
pinned_models = [mlx_shim_model_id] if mlx_shim_model_id else ([req.repo_id] if req.repo_id else [])
|
||||
launched_model = mlx_shim_model_id or req.repo_id
|
||||
pinned_models = [launched_model] if launched_model else []
|
||||
|
||||
# Launch registration must not delete other endpoints.
|
||||
# Matching names, model pins, and failed probes do not prove that
|
||||
# another serve has stopped. Lifecycle cleanup owns termination.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
||||
@@ -1902,19 +1776,22 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "llm"
|
||||
existing.name = display_name
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "auto"
|
||||
if pinned_models:
|
||||
try:
|
||||
existing_pinned = json.loads(existing.pinned_models or "[]")
|
||||
except Exception:
|
||||
existing_pinned = []
|
||||
merged_pinned = []
|
||||
for mid in [*existing_pinned, *pinned_models]:
|
||||
if mid and mid not in merged_pinned:
|
||||
merged_pinned.append(mid)
|
||||
existing.pinned_models = json.dumps(merged_pinned) if merged_pinned else None
|
||||
# A reused endpoint belongs to the newest serve session.
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if launched_model:
|
||||
# Pins represent model selection, not launch history.
|
||||
existing_pins = _normalize_model_ids(
|
||||
existing.pinned_models
|
||||
)
|
||||
if launched_model not in existing_pins:
|
||||
existing_pins.append(launched_model)
|
||||
existing.pinned_models = json.dumps(existing_pins)
|
||||
if is_ollama_endpoint:
|
||||
existing.endpoint_kind = "ollama"
|
||||
if pinned_models:
|
||||
@@ -1940,22 +1817,6 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
db.commit()
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||
# Sweep stale dupes: other endpoints with the same display name
|
||||
# at DIFFERENT URLs (likely failed earlier-attempt ports) get
|
||||
# deleted so the picker doesn't show an offline ghost next to
|
||||
# the working one. Only sweeps endpoints whose id starts with
|
||||
# `local-` so we never touch a user's hand-added DeepSeek/OpenAI/
|
||||
# etc. entry with a coincidentally matching name.
|
||||
stale = (db.query(ModelEndpoint)
|
||||
.filter(ModelEndpoint.name == display_name)
|
||||
.filter(ModelEndpoint.base_url != base_url)
|
||||
.filter(ModelEndpoint.id.like("local-%"))
|
||||
.all())
|
||||
for s in stale:
|
||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
||||
db.delete(s)
|
||||
if stale:
|
||||
db.commit()
|
||||
return existing.id
|
||||
|
||||
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
||||
@@ -1971,22 +1832,11 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
supports_tools=supports_tools,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
||||
# Same sweep on first-register path: drop any pre-existing local-*
|
||||
# endpoints with this display name pointed elsewhere.
|
||||
stale = (db.query(ModelEndpoint)
|
||||
.filter(ModelEndpoint.name == display_name)
|
||||
.filter(ModelEndpoint.id != ep_id)
|
||||
.filter(ModelEndpoint.id.like("local-%"))
|
||||
.all())
|
||||
for s in stale:
|
||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
||||
db.delete(s)
|
||||
if stale:
|
||||
db.commit()
|
||||
# Probe /v1/models NOW and write cached_models so the chat
|
||||
# picker actually shows the model on the next /api/models
|
||||
# call. Without this immediate probe, the endpoint has empty
|
||||
@@ -2808,9 +2658,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
endpoint_id = None
|
||||
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
|
||||
if is_image_endpoint:
|
||||
endpoint_id = _auto_register_image_endpoint(req, remote)
|
||||
endpoint_id = _auto_register_image_endpoint(
|
||||
req,
|
||||
remote,
|
||||
serve_session_id=session_id,
|
||||
)
|
||||
elif not is_pip_install:
|
||||
endpoint_id = _auto_register_llm_endpoint(req, remote)
|
||||
endpoint_id = _auto_register_llm_endpoint(
|
||||
req,
|
||||
remote,
|
||||
serve_session_id=session_id,
|
||||
)
|
||||
|
||||
# Crash watchdog: the auto-register above writes the endpoint row
|
||||
# IMMEDIATELY (before the server has even bound its port) so the
|
||||
@@ -3930,7 +3788,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.exception("HF GGUF file scan failed for %s", repo)
|
||||
logger.exception("HF GGUF file scan failed for %s", repo_id)
|
||||
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||
files = [
|
||||
str(s.get("rfilename") or "")
|
||||
@@ -4302,7 +4160,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
tmux output can stop at a stale progress line if the pane/session
|
||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||
In that case, trust the cache shape: a snapshot directory with files
|
||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
||||
and no unfinished blobs means HuggingFace finished materializing the
|
||||
model. cache_root is the task's custom download dir — the runner
|
||||
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||
not wherever this probe's environment says.
|
||||
@@ -4327,7 +4185,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"""Best-effort check for resumable HF partial blobs.
|
||||
|
||||
A lost SSH/tmux session can leave a real download still incomplete.
|
||||
Treat any *.incomplete blob as stronger evidence than stale
|
||||
Treat unfinished blobs (excluding empty markers paired with a
|
||||
completed blob) as stronger evidence than stale
|
||||
"100%" lines in the captured pane output.
|
||||
"""
|
||||
if not repo_id or "/" not in repo_id:
|
||||
@@ -4445,7 +4304,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# fills the visible tail. Without this, output_tail ends up
|
||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||
# can't diagnose the actual error.
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
||||
# -J joins lines tmux wrapped at the pane width, so an error
|
||||
# split mid-word still matches the serve diagnosis patterns.
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
|
||||
elif IS_WINDOWS:
|
||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||
# Liveness comes from the <session>.pid file, output from the
|
||||
@@ -4454,7 +4315,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
capture_cmd = None
|
||||
else:
|
||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
|
||||
|
||||
local_win_task = (not remote) and IS_WINDOWS
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
from src.session_search import _escape_like
|
||||
from src.upload_handler import reserve_upload_references
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -571,9 +572,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
# match docs containing both words regardless of position/order.
|
||||
if search:
|
||||
for tok in search.split():
|
||||
term = f"%{tok}%"
|
||||
term = f"%{_escape_like(tok)}%"
|
||||
q = q.filter(
|
||||
Document.title.ilike(term) | Document.current_content.ilike(term)
|
||||
Document.title.ilike(term, escape="\\")
|
||||
| Document.current_content.ilike(term, escape="\\")
|
||||
)
|
||||
|
||||
# Language filter. "pdf" is a display language derived from the
|
||||
@@ -781,9 +783,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
|
||||
data = {}
|
||||
ids = data.get("ids") or []
|
||||
ids = data.get("ids") if isinstance(data, dict) else None
|
||||
if not ids:
|
||||
raise HTTPException(400, "No documents specified")
|
||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||
raise HTTPException(400, "ids must be a list of document ids")
|
||||
_ext = {
|
||||
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
|
||||
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
|
||||
@@ -810,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
ext = _ext.get(doc.language or "text", ".txt")
|
||||
base = (doc.title or "document").strip() or "document"
|
||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
||||
name = base if "." in base else base + ext
|
||||
has_ext = base.lower().endswith(ext)
|
||||
stem = base[:-len(ext)] if has_ext else base
|
||||
suffix = base[-len(ext):] if has_ext else ext
|
||||
name = stem + suffix
|
||||
i = 1
|
||||
while name in used:
|
||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
||||
name = f"{stem}-{i}{suffix}"
|
||||
i += 1
|
||||
used.add(name)
|
||||
content = doc.current_content or ""
|
||||
|
||||
@@ -846,6 +846,64 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
|
||||
return None
|
||||
|
||||
|
||||
def _is_background_removal_intent(prompt: str) -> bool:
|
||||
"""Linear-time, bounded intent parser for background removal commands.
|
||||
|
||||
Replaces the previous backtracking regular expression flagged by CodeQL
|
||||
(#1212) while preserving the exact command grammar.
|
||||
"""
|
||||
if not prompt or not isinstance(prompt, str):
|
||||
return False
|
||||
i = len(prompt)
|
||||
while i > 0 and prompt[i - 1] in ".! \t\r\n":
|
||||
i -= 1
|
||||
cleaned = prompt[:i].strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
|
||||
tokens = cleaned.split()
|
||||
if not tokens:
|
||||
return False
|
||||
|
||||
idx = 0
|
||||
if tokens[idx].lower() == "please":
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() not in {"remove", "erase", "delete", "clear", "strip"}:
|
||||
return False
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() == "the":
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() not in {"bg", "background", "backdrop"}:
|
||||
return False
|
||||
idx += 1
|
||||
|
||||
if idx == len(tokens):
|
||||
return True
|
||||
|
||||
if tokens[idx].lower() in {"from", "of"}:
|
||||
idx += 1
|
||||
if idx < len(tokens) and tokens[idx].lower() == "the":
|
||||
idx += 1
|
||||
if (
|
||||
idx + 1 < len(tokens)
|
||||
and tokens[idx].lower() in {"active", "current", "selected"}
|
||||
and tokens[idx + 1].lower() == "layer"
|
||||
and idx + 2 == len(tokens)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _plan_special(
|
||||
prompt: str, state: Any, provider_available: bool,
|
||||
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
|
||||
@@ -855,6 +913,8 @@ def _plan_special(
|
||||
left to the model. Returns (steps, reply, note) or None. These never need an
|
||||
image provider except generative border fill, used only when one exists.
|
||||
"""
|
||||
if _is_background_removal_intent(prompt):
|
||||
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
|
||||
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
||||
# and must never be left to the model, which sometimes substitutes a
|
||||
# generative placeImage. Highest priority so it can never vary.
|
||||
@@ -1647,6 +1707,13 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
screenshot = body.get("screenshot")
|
||||
# Optional user-picked image model (from the editor model selector).
|
||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||
generation_model_choice = str(body.get("generation_model") or "").strip()
|
||||
use_image_settings = body.get("_use_image_settings") is True
|
||||
if use_image_settings:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
image_models = editor_image_models(owner=owner)
|
||||
model_choice = image_models["edit"]
|
||||
generation_model_choice = image_models["generation"]
|
||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||
agent_model = str(body.get("agent_model") or "").strip()
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
@@ -1797,9 +1864,22 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
tool_payload["session"] = session
|
||||
if use_image_settings:
|
||||
tool_payload["_use_image_settings"] = True
|
||||
if op == "generate_image" and state.get("width") and state.get("height"):
|
||||
try:
|
||||
width, height = int(state["width"]), int(state["height"])
|
||||
if width > 0 and height > 0:
|
||||
tool_payload["size"] = f"{width}x{height}"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# Route to the user-picked model when one is selected.
|
||||
if model_choice:
|
||||
tool_payload["model"] = model_choice
|
||||
selected_model = generation_model_choice if op == "generate_image" else model_choice
|
||||
if selected_model:
|
||||
tool_payload["model"] = selected_model
|
||||
elif op == "generate_image" or use_image_settings:
|
||||
# Let server settings resolve Auto, not a planner-invented model.
|
||||
tool_payload.pop("model", None)
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
|
||||
@@ -19,9 +19,10 @@ def _split_family_token(token):
|
||||
def _derive_family(filename):
|
||||
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
|
||||
name = os.path.splitext(filename)[0]
|
||||
# Strip common weight/style suffixes
|
||||
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
|
||||
name = re.sub(
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
|
||||
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
|
||||
r'(?:[-_ ]?(?:Italic|Oblique))?$',
|
||||
'', name, flags=re.IGNORECASE
|
||||
)
|
||||
# Replace dashes/underscores with spaces
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||
from src.session_search import escape_like_pattern
|
||||
from src.upload_limits import (
|
||||
read_upload_limited,
|
||||
GALLERY_UPLOAD_MAX_BYTES,
|
||||
@@ -37,6 +38,24 @@ _SAM_STATE: Dict[str, Any] = {}
|
||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
|
||||
from PIL import Image, ImageChops, ImageOps
|
||||
|
||||
full_frame = instruction or mask.getextrema() == (255, 255)
|
||||
if full_frame:
|
||||
return generated, True
|
||||
# Partial edits must share the source coordinate system, but resizing
|
||||
# width/height independently distorts the subject when the provider changes ratio.
|
||||
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
|
||||
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
|
||||
aligned = Image.new("RGBA", source.size)
|
||||
aligned.paste(fitted, offset)
|
||||
coverage = Image.new("L", source.size)
|
||||
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
|
||||
effective_mask = ImageChops.multiply(mask, coverage)
|
||||
return Image.composite(aligned, source, effective_mask), False
|
||||
|
||||
|
||||
def _rss_mb() -> float:
|
||||
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
||||
try:
|
||||
@@ -305,6 +324,23 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
|
||||
raise HTTPException(400, f"Invalid {label}") from exc
|
||||
|
||||
|
||||
async def _json_object(request: Request) -> dict:
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(400, "JSON object body required")
|
||||
return data
|
||||
|
||||
|
||||
async def _image_ids(request: Request) -> list:
|
||||
ids = (await _json_object(request)).get("image_ids", [])
|
||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||
raise HTTPException(400, "image_ids must be a list of image ids")
|
||||
return ids
|
||||
|
||||
|
||||
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=fmt)
|
||||
@@ -568,6 +604,7 @@ def _is_openai_api_base(url: str) -> bool:
|
||||
|
||||
|
||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||
"/chat/completions",
|
||||
"/images",
|
||||
"/images/edits",
|
||||
"/images/generations",
|
||||
@@ -1184,15 +1221,18 @@ def setup_gallery_routes() -> APIRouter:
|
||||
)
|
||||
q = _owner_filter(q, user)
|
||||
|
||||
# Search filter (prompt + tags + ai_tags)
|
||||
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||
# exact phrase, so "red car" missed "a car that is red".
|
||||
# LIKE wildcards are escaped per token.
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
from sqlalchemy import or_
|
||||
q = q.filter(or_(
|
||||
GalleryImage.prompt.ilike(term),
|
||||
GalleryImage.tags.ilike(term),
|
||||
GalleryImage.ai_tags.ilike(term),
|
||||
))
|
||||
for tok in search.split():
|
||||
term = f"%{escape_like_pattern(tok)}%"
|
||||
q = q.filter(or_(
|
||||
GalleryImage.prompt.ilike(term, escape="\\"),
|
||||
GalleryImage.tags.ilike(term, escape="\\"),
|
||||
GalleryImage.ai_tags.ilike(term, escape="\\"),
|
||||
))
|
||||
|
||||
# Tag filter. The UI stacks multiple tag pills by passing them
|
||||
# comma-separated — each tag adds a separate AND-filter so the
|
||||
@@ -1203,9 +1243,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
for one in (t.strip() for t in tag.split(",")):
|
||||
if not one:
|
||||
continue
|
||||
safe_tag = f"%{escape_like_pattern(one)}%"
|
||||
q = q.filter(_or(
|
||||
GalleryImage.tags.ilike(f"%{one}%"),
|
||||
GalleryImage.ai_tags.ilike(f"%{one}%"),
|
||||
GalleryImage.tags.ilike(safe_tag, escape="\\"),
|
||||
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
|
||||
))
|
||||
|
||||
# Model filter
|
||||
@@ -1745,6 +1786,17 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
chosen_model = (body.pop("_model", "") or "").strip()
|
||||
if body.pop("_use_image_settings", False) is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
from src.ai_interaction import _resolve_model
|
||||
chosen_model = editor_image_models(owner=user)["edit"]
|
||||
if not chosen_model:
|
||||
raise HTTPException(400, "Choose an image edit model in AI settings")
|
||||
try:
|
||||
edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
requested_base = edit_url.removesuffix("/chat/completions").rstrip("/")
|
||||
# Optional client-supplied id so GET /api/image/edit-progress can poll the
|
||||
# provider's step progress for this op. Ignored by providers that do not
|
||||
# implement the progress contract.
|
||||
@@ -1862,14 +1914,30 @@ def setup_gallery_routes() -> APIRouter:
|
||||
mask_png.save(reference_mask, format="PNG")
|
||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||
payload = {
|
||||
"model": oa_model, "size": size, "n": 1,
|
||||
"model": oa_model, "n": 1,
|
||||
"output_format": "png",
|
||||
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
||||
"input_references": [{"type": "image_url", "image_url": {
|
||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||
}} for value in references],
|
||||
}
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height)
|
||||
from src.ai_interaction import _uses_openrouter_chat_images
|
||||
if _uses_openrouter_chat_images(oa_model):
|
||||
payload = {
|
||||
"model": oa_model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": payload["prompt"]},
|
||||
*[{"type": "image_url", "image_url": ref["image_url"]}
|
||||
for ref in payload["input_references"]],
|
||||
]}],
|
||||
"modalities": ["image", "text"], "stream": False,
|
||||
"image_config": {"aspect_ratio": payload["aspect_ratio"]},
|
||||
}
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
if r.status_code != 200:
|
||||
@@ -1885,6 +1953,12 @@ def setup_gallery_routes() -> APIRouter:
|
||||
pass
|
||||
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
||||
result = r.json()
|
||||
from src.ai_interaction import _chat_image_results, _image_response_error
|
||||
provider_error = _image_response_error(result, oa_model)
|
||||
if provider_error:
|
||||
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
|
||||
if is_openrouter and _uses_openrouter_chat_images(oa_model):
|
||||
result = {"data": _chat_image_results(result)}
|
||||
raw_b64 = None
|
||||
if result.get("data"):
|
||||
item = result["data"][0]
|
||||
@@ -1904,17 +1978,13 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# the masked region actually changes.
|
||||
try:
|
||||
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
||||
# Match the generated image to the source dims.
|
||||
if generated.size != source_png.size:
|
||||
generated = generated.resize(source_png.size, Image.LANCZOS)
|
||||
# mask_png: white = regenerate (use generated),
|
||||
# black = keep (use source).
|
||||
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
|
||||
# Image.composite does exactly that with `mask`.
|
||||
blended = Image.composite(generated, source_png, mask_png)
|
||||
blended, full_frame = _compose_cloud_image_edit(
|
||||
source_png, generated, mask_png,
|
||||
instruction=body.get("mode") == "instruction",
|
||||
)
|
||||
out_buf = io.BytesIO()
|
||||
blended.save(out_buf, format="PNG")
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model}
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
|
||||
except Exception as comp_err:
|
||||
# Never return a full-image edit when masking fails.
|
||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||
@@ -2433,11 +2503,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
image_b64 = body.get("image")
|
||||
amount = body.get("amount", 50) / 100.0
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
from PIL import ImageFilter
|
||||
import base64, io
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
|
||||
# Unsharp mask: radius=2, percent=amount*200, threshold=3
|
||||
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
|
||||
@@ -2472,8 +2541,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from realesrgan import RealESRGANer
|
||||
@@ -2525,8 +2593,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
raise HTTPException(500, "Server missing a required dependency")
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
src = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
try:
|
||||
patch_realesrgan_torchvision_compat()
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
@@ -2574,7 +2641,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
height = int((body or {}).get("height") or 0)
|
||||
except (TypeError, ValueError):
|
||||
width = height = 0
|
||||
if height > width:
|
||||
if width > 0 and height > 0:
|
||||
size = f"{width}x{height}"
|
||||
elif height > width:
|
||||
size = "1024x1536"
|
||||
elif width > height:
|
||||
size = "1536x1024"
|
||||
@@ -2587,6 +2656,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# The editor's model picker may choose a specific (cloud) image model;
|
||||
# forward it so settings defaults only apply when none is selected.
|
||||
model_choice = str((body or {}).get("model") or "").strip()
|
||||
if body.get("_use_image_settings") is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
model_choice = editor_image_models(owner=user)["generation"]
|
||||
payload = {"prompt": prompt, "size": size}
|
||||
if model_choice:
|
||||
payload["model"] = model_choice
|
||||
@@ -2778,8 +2850,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
from PIL import Image
|
||||
import base64, io
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
||||
img = _b64_to_pil_image(image_b64)
|
||||
W, H = img.size
|
||||
|
||||
hint = None
|
||||
@@ -2868,8 +2939,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
from PIL import Image, ImageFilter, ImageEnhance
|
||||
import numpy as np
|
||||
|
||||
img_bytes = _b64_bytes(image_b64)
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||
img = _b64_to_pil_image(image_b64, mode="RGB")
|
||||
|
||||
# Try GFPGAN first (AI face restoration)
|
||||
try:
|
||||
@@ -2944,14 +3014,22 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.put("/api/gallery/albums/{album_id}")
|
||||
async def update_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
data = await _json_object(request)
|
||||
name = data.get("name")
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise HTTPException(400, "Album name required")
|
||||
name = name.strip()
|
||||
description = data.get("description")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise HTTPException(400, "Album description must be a string")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
album = _get_or_404_album(db, album_id, user)
|
||||
if data.get("name") is not None:
|
||||
album.name = data["name"]
|
||||
if data.get("description") is not None:
|
||||
album.description = data["description"]
|
||||
if name is not None:
|
||||
album.name = name
|
||||
if description is not None:
|
||||
album.description = description
|
||||
if data.get("cover_id") is not None:
|
||||
cover_id = data["cover_id"] or None
|
||||
if cover_id:
|
||||
@@ -2981,8 +3059,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/add")
|
||||
async def add_to_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
ids = await _image_ids(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
@@ -2999,8 +3076,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||
async def remove_from_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
ids = await _image_ids(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
|
||||
@@ -825,6 +825,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
# A message id is exact; keep_count from the client counts only the
|
||||
# messages loaded in the page. before_msg_id forks before that
|
||||
# message, through_msg_id includes it.
|
||||
before_msg_id = str(body.get("before_msg_id") or "").strip()
|
||||
through_msg_id = str(body.get("through_msg_id") or "").strip()
|
||||
if before_msg_id or through_msg_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
finally:
|
||||
db.close()
|
||||
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved + (0 if before_msg_id else 1)
|
||||
|
||||
# Get the source session. keep_count indexes into source.history,
|
||||
# so this must go through get_session — reading the cache directly
|
||||
|
||||
+30
-5
@@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||
"topic-control", "calibration",
|
||||
"ai-synthetic-video", "cosmos-reason2",
|
||||
"bge", "llama-guard",
|
||||
"bge", "llama-guard", "-embed", "rerank",
|
||||
)
|
||||
_NON_CHAT_EXACT_PREFIXES = (
|
||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
||||
@@ -2846,11 +2846,12 @@ def setup_model_routes(model_discovery):
|
||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
chat_url = build_chat_url(base)
|
||||
if model and not _is_chat_model(model):
|
||||
model = ""
|
||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
try:
|
||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||
if visible:
|
||||
model = visible[0]
|
||||
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||
except Exception:
|
||||
pass
|
||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||
@@ -3043,20 +3044,44 @@ def setup_model_routes(model_discovery):
|
||||
return {"dependents": _settings_using_endpoint(ep_id)}
|
||||
|
||||
@router.delete("/model-endpoints/{ep_id}")
|
||||
def delete_model_endpoint(ep_id: str, request: Request):
|
||||
def delete_model_endpoint(
|
||||
ep_id: str,
|
||||
request: Request,
|
||||
expected_cookbook_serve_session_id: Optional[str] = None,
|
||||
):
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
if expected_cookbook_serve_session_id is not None:
|
||||
# The DELETE itself checks ownership. An old serve must never
|
||||
# mutate the settings of an endpoint reused by another serve.
|
||||
if (
|
||||
not ep_id.startswith(("local-", "img-"))
|
||||
or not expected_cookbook_serve_session_id
|
||||
):
|
||||
raise HTTPException(409, "Cookbook endpoint ownership mismatch")
|
||||
deleted = (
|
||||
db.query(ModelEndpoint)
|
||||
.filter(
|
||||
ModelEndpoint.id == ep_id,
|
||||
ModelEndpoint.cookbook_serve_session_id
|
||||
== expected_cookbook_serve_session_id,
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(409, "Cookbook endpoint ownership changed")
|
||||
# Clean up any settings that reference this endpoint
|
||||
cleared = _clear_settings_for_endpoint(ep_id)
|
||||
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
|
||||
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
|
||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
|
||||
auth_id = getattr(ep, "provider_auth_id", None)
|
||||
db.delete(ep)
|
||||
if expected_cookbook_serve_session_id is None:
|
||||
db.delete(ep)
|
||||
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
|
||||
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
|
||||
)
|
||||
if body.title is not None:
|
||||
note.title = body.title
|
||||
if body.content is not None:
|
||||
# Omitted fields preserve their values; explicit null clears the
|
||||
# nullable fields used by the editor's removal controls.
|
||||
supplied = body.model_fields_set
|
||||
if "content" in supplied:
|
||||
note.content = body.content
|
||||
if body.items is not None:
|
||||
note.items = json.dumps(body.items)
|
||||
if "items" in supplied:
|
||||
note.items = json.dumps(body.items) if body.items is not None else None
|
||||
flag_modified(note, "items")
|
||||
if body.note_type is not None:
|
||||
note.note_type = body.note_type
|
||||
if body.color is not None:
|
||||
if "color" in supplied:
|
||||
note.color = body.color
|
||||
if body.label is not None:
|
||||
if "label" in supplied:
|
||||
note.label = body.label
|
||||
if body.pinned is not None:
|
||||
note.pinned = body.pinned
|
||||
if body.archived is not None:
|
||||
note.archived = body.archived
|
||||
if body.due_date is not None:
|
||||
if "due_date" in supplied:
|
||||
note.due_date = body.due_date
|
||||
if body.image_url is not None:
|
||||
if "image_url" in supplied:
|
||||
note.image_url = body.image_url
|
||||
if body.gallery_id is not None:
|
||||
if "gallery_id" in supplied:
|
||||
note.gallery_id = body.gallery_id
|
||||
if body.repeat is not None:
|
||||
note.repeat = body.repeat
|
||||
if body.sort_order is not None:
|
||||
note.sort_order = body.sort_order
|
||||
if body.agent_session_id is not None:
|
||||
if "agent_session_id" in supplied:
|
||||
note.agent_session_id = body.agent_session_id
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -118,8 +118,12 @@ def setup_prefs_routes():
|
||||
async def set_pref(request: Request, key: str, body: dict):
|
||||
user = get_current_user(request)
|
||||
prefs = _load_for_user(user)
|
||||
prefs[key] = body.get("value")
|
||||
val = body.get("value")
|
||||
if val is None:
|
||||
prefs.pop(key, None)
|
||||
else:
|
||||
prefs[key] = val
|
||||
_save_for_user(user, prefs)
|
||||
return {"key": key, "value": prefs[key]}
|
||||
return {"key": key, "value": prefs.get(key)}
|
||||
|
||||
return router
|
||||
|
||||
@@ -183,6 +183,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||
"""
|
||||
from src.endpoint_resolver import (
|
||||
_endpoint_enabled_models,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||
@@ -197,7 +198,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
ep_model = (model or "").strip()
|
||||
if not ep_model:
|
||||
try:
|
||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||
models = _endpoint_enabled_models(ep)
|
||||
if models:
|
||||
ep_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
@@ -824,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
fallback_headers = build_headers(ep.api_key, base)
|
||||
fallback_model = ""
|
||||
if ep.cached_models:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
_merge(*resolved)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.memory.skill_format import slugify
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from services.skills import evaluation as _evaluation
|
||||
@@ -836,6 +837,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
updates = body.dict(exclude_none=True)
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
if "name" in updates:
|
||||
new_name = slugify(updates["name"] or match.get("name"))
|
||||
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
||||
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
||||
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
|
||||
+11
-17
@@ -20,7 +20,7 @@ from src.task_action_policy import (
|
||||
is_admin_only_task_action,
|
||||
owner_has_admin_task_privileges,
|
||||
)
|
||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
||||
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
|
||||
eps = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint, getattr(task, "owner", None),
|
||||
).all()
|
||||
for ep in eps:
|
||||
cached = []
|
||||
if ep.cached_models:
|
||||
try:
|
||||
cached = json.loads(ep.cached_models) or []
|
||||
except Exception:
|
||||
cached = []
|
||||
if model in cached:
|
||||
if model in _endpoint_enabled_models(ep):
|
||||
return ep.base_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
@@ -475,10 +475,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
if req.trigger_type == "event" and not req.trigger_event:
|
||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
||||
@@ -788,10 +785,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
task.character_id = req.character_id or None
|
||||
if req.cron_expression is not None:
|
||||
if req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
if not is_valid_cron(req.cron_expression):
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
task.cron_expression = req.cron_expression or None
|
||||
|
||||
|
||||
@@ -492,6 +492,25 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
||||
"tools that execute code, mutate state or cause external side effects need "
|
||||
"a separate approval. Set to 0 to opt out.",
|
||||
),
|
||||
"ODYSSEUS_LOCAL_MCP_READ_TOOLS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated exact MCP read-tool names "
|
||||
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||
"Declarations do not override built-in tool effects. Output remains "
|
||||
"untrusted to prevent privilege escalation, but allows continuation to "
|
||||
"declared write tools on the same stdio server without external taint. "
|
||||
"Stdio transport does not guarantee network isolation. Automatic "
|
||||
"same-store writes carry residual prompt-injection risk.",
|
||||
),
|
||||
"ODYSSEUS_LOCAL_MCP_WRITE_TOOLS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated exact MCP write-tool names "
|
||||
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||
"Declarations do not override built-in tool effects. Allowed to execute "
|
||||
"after declared local reads on the same stdio server, but blocked once "
|
||||
"external untrusted context enters the run. Authorization must still "
|
||||
"originate from the trusted request grant.",
|
||||
),
|
||||
"ODYSSEUS_MCP_ALLOWED_COMMANDS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated allowlist of MCP launcher basenames the "
|
||||
|
||||
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
|
||||
album, and surfaces EXIF metadata.
|
||||
|
||||
odysseus-gallery list --limit 20 --favorites
|
||||
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
|
||||
odysseus-gallery show IMAGE_ID
|
||||
odysseus-gallery albums | jq -r '.[] | .name'
|
||||
odysseus-gallery search "sunset" # matches prompt + tags
|
||||
@@ -83,7 +84,13 @@ def cmd_list(args):
|
||||
fail(f"no album named {args.album!r}")
|
||||
q = q.filter(GalleryImage.album_id == al.id)
|
||||
if args.tag:
|
||||
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
|
||||
for tag in (value.strip() for value in args.tag.split(",")):
|
||||
if not tag:
|
||||
continue
|
||||
q = q.filter(
|
||||
GalleryImage.tags.ilike(f"%{tag}%") |
|
||||
GalleryImage.ai_tags.ilike(f"%{tag}%")
|
||||
)
|
||||
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
|
||||
emit([_serialize_image(i) for i in q.all()], args)
|
||||
finally:
|
||||
@@ -159,7 +166,7 @@ def _build_parser():
|
||||
pl.add_argument("--limit", type=int, default=50)
|
||||
pl.add_argument("--favorites", action="store_true")
|
||||
pl.add_argument("--album", help="filter by album name")
|
||||
pl.add_argument("--tag", help="substring match against tags column")
|
||||
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
|
||||
pl.set_defaults(func=cmd_list)
|
||||
|
||||
psh = sub.add_parser("show", parents=[common])
|
||||
|
||||
+12
-6
@@ -43,7 +43,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Anchor the path so the script works when invoked from anywhere.
|
||||
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
|
||||
emit([], args)
|
||||
return
|
||||
all_uids = data[0].split()
|
||||
# Newest first, limit
|
||||
# Keep a bounded arrival batch; order its decoded rows by Date below.
|
||||
uids = list(reversed(all_uids))[: args.limit]
|
||||
out = []
|
||||
for uid in uids:
|
||||
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
|
||||
sender_name, sender_addr = parseaddr(sender)
|
||||
date_raw = msg.get("Date", "")
|
||||
try:
|
||||
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
|
||||
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
|
||||
iso = parsed_date.isoformat() if parsed_date else ""
|
||||
if parsed_date is not None and parsed_date.tzinfo is None:
|
||||
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
|
||||
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
|
||||
except Exception:
|
||||
iso = ""
|
||||
out.append({
|
||||
date_epoch = float("-inf")
|
||||
out.append((date_epoch, {
|
||||
"uid": uid.decode(),
|
||||
"date": iso,
|
||||
"from_addr": sender_addr,
|
||||
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
|
||||
"subject": subject,
|
||||
"is_read": "\\Seen" in flags,
|
||||
"is_answered": "\\Answered" in flags,
|
||||
})
|
||||
}))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
|
||||
emit(out, args)
|
||||
out.sort(key=lambda item: item[0], reverse=True)
|
||||
emit([row for _, row in out], args)
|
||||
|
||||
|
||||
# ─── read ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,6 +11,38 @@ import time
|
||||
from urllib.request import urlopen
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def _stop_owned_server(server, identity):
|
||||
"""Close only identity-bound descendants, including MCP's new sessions.
|
||||
|
||||
Capture before signalling the parent: an orphaned stderr holder can no
|
||||
longer be attributed by ancestry. Never search by executable name or kill
|
||||
the caller's process group.
|
||||
"""
|
||||
from src import process_lifecycle as lifecycle
|
||||
|
||||
descendants = (lifecycle.bind_descendants([identity.pid])
|
||||
if identity.verdict() == lifecycle.OWNED else [])
|
||||
if os.name == "nt":
|
||||
if identity.verdict() == lifecycle.OWNED:
|
||||
lifecycle.taskkill_tree(identity.pid)
|
||||
else:
|
||||
outcome = lifecycle.terminate_identities(
|
||||
[*(entry.identity for entry in reversed(descendants)), identity],
|
||||
steps=lifecycle.term_kill_steps(10),
|
||||
)
|
||||
if outcome.survivors or outcome.unverified:
|
||||
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=2)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
# Do not turn an unidentified pipe holder into an unbounded wait.
|
||||
server.stdout.close()
|
||||
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
|
||||
return logs
|
||||
|
||||
|
||||
def main():
|
||||
@@ -36,7 +68,10 @@ def main():
|
||||
server = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
|
||||
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
start_new_session=(os.name != "nt"),
|
||||
)
|
||||
from src.process_lifecycle import ProcessIdentity
|
||||
server_identity = ProcessIdentity.capture(server.pid)
|
||||
result = None
|
||||
try:
|
||||
origin = f"http://127.0.0.1:{port}"
|
||||
@@ -62,12 +97,7 @@ def main():
|
||||
print(result.stderr, file=sys.stderr)
|
||||
return result.returncode
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
logs, _ = server.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
logs, _ = server.communicate()
|
||||
logs = _stop_owned_server(server, server_identity)
|
||||
if result is None or result.returncode:
|
||||
print(logs[-8000:], file=sys.stderr)
|
||||
|
||||
|
||||
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
|
||||
return "general"
|
||||
|
||||
|
||||
# HF collections mix in models no Cookbook LLM backend can serve (vision
|
||||
# encoders, time-series forecasters, image/video generators). Image models
|
||||
# have their own registry, so keep these out of the LLM catalog.
|
||||
_NON_LLM_PIPELINE_TAGS = frozenset({
|
||||
"time-series-forecasting",
|
||||
"zero-shot-image-classification",
|
||||
"image-classification",
|
||||
"image-feature-extraction",
|
||||
"text-to-image",
|
||||
"image-to-video",
|
||||
"text-to-video",
|
||||
})
|
||||
|
||||
|
||||
def _entry_from_collection_item(collection, item, source):
|
||||
repo_id = item.get("id") or ""
|
||||
if item.get("type") != "model" or not repo_id:
|
||||
return None
|
||||
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
|
||||
return None
|
||||
repo_prefix = source.get("repo_prefix")
|
||||
if repo_prefix and not repo_id.startswith(repo_prefix):
|
||||
return None
|
||||
raw_params = item.get("numParameters") or 0
|
||||
active = None
|
||||
# Read the name even when HF gives a total: it is the only source of the
|
||||
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
|
||||
name_params, active = _parse_params_from_name(repo_id)
|
||||
if not raw_params:
|
||||
raw_params, active = _parse_params_from_name(repo_id)
|
||||
raw_params = name_params
|
||||
param_label, raw_params = _format_params(raw_params)
|
||||
if not raw_params:
|
||||
return None
|
||||
|
||||
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
|
||||
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
|
||||
|
||||
|
||||
def _parse_scalar(raw: str) -> Any:
|
||||
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||
raw = raw.strip()
|
||||
if raw == "":
|
||||
return ""
|
||||
# String fields keep their spelling; quoted values still use the decoder
|
||||
# below so existing escapes round-trip as before.
|
||||
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
|
||||
return raw
|
||||
if raw.startswith("[") and raw.endswith("]"):
|
||||
inner = raw[1:-1].strip()
|
||||
if not inner:
|
||||
@@ -126,10 +130,15 @@ def _parse_scalar(raw: str) -> Any:
|
||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||
out, buf, depth, quote = [], [], 0, None
|
||||
escaped = False
|
||||
for ch in s:
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\" and quote == '"':
|
||||
escaped = True
|
||||
elif ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
@@ -171,7 +180,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
pending_key = key
|
||||
fm[key] = []
|
||||
else:
|
||||
fm[key] = _parse_scalar(val)
|
||||
fm[key] = _parse_scalar(val, string_only=(key == "version"))
|
||||
pending_key = None
|
||||
continue
|
||||
m2 = _FM_BLOCK_LIST_RE.match(line)
|
||||
|
||||
@@ -500,6 +500,19 @@ class SkillsManager:
|
||||
sk.path = self._skill_file(cat, nm)
|
||||
return sk.to_dict()
|
||||
|
||||
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
|
||||
"""Whether `owner` already has a skill called `name`, in any category.
|
||||
|
||||
Update and delete find a skill by name within its owner, so two of
|
||||
one owner's skills sharing a name would make them act on whichever
|
||||
file is walked first.
|
||||
"""
|
||||
for path in self._iter_skill_files():
|
||||
sk = self._read_skill(path)
|
||||
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||
"""`skill_id` is the slug name. Allows updating any field plus
|
||||
renames if `name` changes (file is moved on disk).
|
||||
@@ -551,6 +564,9 @@ class SkillsManager:
|
||||
# Rename
|
||||
new_name = slugify(updates.get("name") or sk.name)
|
||||
if new_name != sk.name:
|
||||
if self.has_skill(new_name, owner=owner):
|
||||
logger.warning(f"Skill rename target name in use: {new_name}")
|
||||
return False
|
||||
sk.name = new_name
|
||||
|
||||
# Write to potentially new path
|
||||
|
||||
+23
-11
@@ -24193,11 +24193,14 @@ async def stream_agent_loop(
|
||||
budget_is_explicit,
|
||||
hard_max=hard_max,
|
||||
)
|
||||
if candidate_context <= 8192:
|
||||
if 0 < candidate_context <= 8192:
|
||||
# Small local servers often tokenize chat wrappers and tool
|
||||
# results much more generously than our rough estimator. Keep
|
||||
# substantial headroom for those wrappers and generation so a
|
||||
# follow-up tool round cannot exceed the server's n_ctx.
|
||||
# 0 means the window is unknown, which already gets the
|
||||
# conservative default budget; clamping it as well cut every
|
||||
# request to 1200 tokens and dropped open documents (#6678).
|
||||
effective_budget = min(
|
||||
effective_budget,
|
||||
max(1200, int(candidate_context * 0.40)),
|
||||
@@ -27833,16 +27836,25 @@ async def stream_agent_loop(
|
||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||
_streamed_tool_markup += _delta_text
|
||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||
continue
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
).strip()
|
||||
_streamed_tool_markup = ""
|
||||
if not _visible_markup_tail:
|
||||
continue
|
||||
_delta_text = _visible_markup_tail
|
||||
data["delta"] = _delta_text
|
||||
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||
continue
|
||||
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||
# a tag prefix, but the buffer can no longer become a
|
||||
# tool tag. Release it as prose instead of holding the
|
||||
# rest of the answer forever.
|
||||
_delta_text = _streamed_tool_markup
|
||||
_streamed_tool_markup = ""
|
||||
data["delta"] = _delta_text
|
||||
else:
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
).strip()
|
||||
_streamed_tool_markup = ""
|
||||
if not _visible_markup_tail:
|
||||
continue
|
||||
_delta_text = _visible_markup_tail
|
||||
data["delta"] = _delta_text
|
||||
full_response += _delta_text
|
||||
if not _suppress_unavailable_web_delta:
|
||||
_qwen_buffered_route = bool(
|
||||
|
||||
@@ -371,6 +371,101 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
||||
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
||||
|
||||
|
||||
|
||||
def _trusted_local_mcp_request_tools(request_text: str, intent: SemanticIntent) -> set[str]:
|
||||
"""Seal local MCP operations from operator declarations and trusted request intent.
|
||||
|
||||
Discovery validates an operator-declared tool but cannot grant it. For the
|
||||
notes family, only an explicit request for local notes admits matching
|
||||
read operations and explicitly requested non-destructive write verbs. Other
|
||||
MCP domains require the user to name the qualified operation explicitly.
|
||||
"""
|
||||
from src.tool_capabilities import (
|
||||
LOCAL_MCP_READ_TOOLS_ENV, LOCAL_MCP_WRITE_TOOLS_ENV,
|
||||
_local_mcp_tools, _is_declared_local_mcp_read_tool,
|
||||
_is_declared_local_mcp_write_tool, capabilities_for_tool, ToolEffect,
|
||||
)
|
||||
from src.tool_utils import get_mcp_manager
|
||||
|
||||
manager = get_mcp_manager()
|
||||
if manager is None:
|
||||
return set()
|
||||
|
||||
request = request_text.casefold()
|
||||
local_notes = (
|
||||
intent.selected_tools is None
|
||||
and intent.required_read is None
|
||||
and "notes" in intent.capabilities
|
||||
and re.search(r"\b(?:local|on my computer|on this machine)\b", request)
|
||||
)
|
||||
write_verbs = {
|
||||
"create": r"\b(?:create|add|make)\b",
|
||||
"add": r"\b(?:add|create)\b",
|
||||
"write": r"\bwrite\b",
|
||||
"save": r"\bsave\b",
|
||||
"update": r"\b(?:update|edit|modify|change)\b",
|
||||
"edit": r"\b(?:edit|update|modify|change)\b",
|
||||
"append": r"\bappend\b",
|
||||
"modify": r"\b(?:modify|edit|update|change)\b",
|
||||
}
|
||||
negative_write = re.search(
|
||||
r"\b(?:do not|don't|never|without|no)\s+(?:\w+\s+){0,2}"
|
||||
r"(?:create|add|make|write|save|update|edit|append|modify)\b",
|
||||
request,
|
||||
)
|
||||
declared = _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV) | _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
admitted = set()
|
||||
for name in sorted(declared):
|
||||
parts = name.split("__", 2)
|
||||
if len(parts) != 3 or parts[0] != "mcp":
|
||||
continue
|
||||
_, server, bare = parts
|
||||
if manager.is_builtin(server):
|
||||
continue
|
||||
conn = manager.get_server_status(server)
|
||||
if conn.get("status") != "connected" or conn.get("transport") != "stdio":
|
||||
continue
|
||||
resource = manager.resource_identity(name)
|
||||
if resource is None or resource.namespace != "mcp" or resource.endpoint_id != f"stdio:{server}":
|
||||
continue
|
||||
rows = manager._tools.get(server, [])
|
||||
row = next((item for item in rows if item.get("name") == bare), None)
|
||||
if row is None:
|
||||
continue
|
||||
read_ok = _is_declared_local_mcp_read_tool(name)
|
||||
write_ok = _is_declared_local_mcp_write_tool(name)
|
||||
if not (read_ok or write_ok):
|
||||
continue
|
||||
caps = capabilities_for_tool(name)
|
||||
if not caps.known or caps.effects != frozenset({
|
||||
ToolEffect.READ_PRIVATE if read_ok else ToolEffect.WRITE_PRIVATE
|
||||
}):
|
||||
continue
|
||||
ann = row.get("annotations") or {}
|
||||
destructive = ann.get("destructiveHint") if isinstance(ann, dict) else getattr(ann, "destructiveHint", None)
|
||||
if destructive is True:
|
||||
continue
|
||||
if write_ok and re.match(r"^(?:delete|remove|erase|destroy|drop|purge)(?:_|$)", bare, re.I):
|
||||
continue
|
||||
|
||||
# Exact qualified names in the trusted request permit any configured
|
||||
# and validated local operation. Model/tool content is not an input.
|
||||
explicitly_named = bool(re.search(
|
||||
r"(?<![A-Za-z0-9_])" + re.escape(name.casefold()) + r"(?![A-Za-z0-9_])",
|
||||
request,
|
||||
))
|
||||
if not explicitly_named:
|
||||
if not local_notes or not re.search(r"(?:^|_)notes?(?:_|$)", bare, re.I):
|
||||
continue
|
||||
if write_ok:
|
||||
action = bare.split("_", 1)[0].casefold()
|
||||
if action not in write_verbs or not re.search(write_verbs[action], request):
|
||||
continue
|
||||
if negative_write:
|
||||
continue
|
||||
admitted.add(name)
|
||||
return admitted
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
@@ -393,6 +488,7 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
tools = {canonical_tool(operation.tool)}
|
||||
else:
|
||||
operation = None
|
||||
tools.update(_trusted_local_mcp_request_tools(request_text, intent))
|
||||
grants = []
|
||||
for name in sorted(tools | {"ask_user", "update_plan"}):
|
||||
name = canonical_tool(name)
|
||||
|
||||
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
||||
db.close()
|
||||
|
||||
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
|
||||
params: Dict[str, Any] = {
|
||||
"dataUrl": data_url,
|
||||
"name": name,
|
||||
@@ -199,6 +199,8 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
||||
"blend": blend or "normal",
|
||||
"opacity": opacity,
|
||||
}
|
||||
if native_size:
|
||||
params["nativeSize"] = True
|
||||
# Forward the edit mode so the client can build a difference mask for
|
||||
# instruction-mode results: the whole image was regenerated under an
|
||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||
@@ -506,6 +508,8 @@ class AiEditImageTool:
|
||||
}
|
||||
_t["image"] = time.perf_counter()
|
||||
payload: Dict[str, Any] = {"image": image}
|
||||
if args.get("_use_image_settings") is True:
|
||||
payload["_use_image_settings"] = True
|
||||
model_choice = str(args.get("model") or "").strip()
|
||||
mask_used = False
|
||||
place_mode: Optional[str] = None
|
||||
@@ -609,10 +613,11 @@ class AiEditImageTool:
|
||||
f"AI {op}",
|
||||
owner,
|
||||
session,
|
||||
masked=mask_used,
|
||||
masked=mask_used and data.get("full_frame") is not True,
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
mode=place_mode,
|
||||
native_size=data.get("full_frame") is True,
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
@@ -687,6 +692,7 @@ class GenerateImageLayerTool:
|
||||
str(args.get("name") or "Generated image"),
|
||||
owner,
|
||||
session,
|
||||
native_size=True,
|
||||
masked=bool(args.get("mask")),
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
|
||||
+136
-23
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
|
||||
# served internal id. Include pinned/cached ids in the match set
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if endpoint_reachable:
|
||||
# Gateway image models may live in a dedicated catalogue,
|
||||
# so explicitly pinned IDs are valid on mixed endpoints too.
|
||||
extras = _json_list(getattr(ep, "pinned_models", None))
|
||||
if model_type == "image":
|
||||
extras += _json_list(getattr(ep, "cached_models", None))
|
||||
for extra in extras:
|
||||
if extra not in model_ids:
|
||||
model_ids.append(extra)
|
||||
|
||||
@@ -1176,9 +1181,9 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
request on this so it never falls back to deterministic approximations.
|
||||
"""
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
if not load_settings().get("image_gen_enabled", True):
|
||||
if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1200,6 +1205,39 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _uses_openrouter_chat_images(model_id: str) -> bool:
|
||||
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
|
||||
leaf = model_id_leaf(model_id).lower()
|
||||
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
|
||||
|
||||
|
||||
def _chat_image_results(data: dict) -> list:
|
||||
images = []
|
||||
for choice in data.get("choices") or []:
|
||||
message = choice.get("message") or {}
|
||||
for item in message.get("images") or []:
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
if url.startswith("data:image/") and ";base64," in url:
|
||||
images.append({"b64_json": url.split(",", 1)[1]})
|
||||
elif url.startswith(("https://", "http://")):
|
||||
images.append({"url": url})
|
||||
return images
|
||||
|
||||
|
||||
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
|
||||
errors = [data.get("error")]
|
||||
errors.extend(choice.get("error") for choice in data.get("choices") or [])
|
||||
for error in errors:
|
||||
if not error:
|
||||
continue
|
||||
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
|
||||
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
|
||||
return f"Image provider failed for '{model_id}': {message}{hint}"
|
||||
return None
|
||||
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
@@ -1207,7 +1245,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
Line 1: prompt describing the image
|
||||
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
|
||||
Line 3: size (optional, defaults to 1024x1024)
|
||||
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
|
||||
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
|
||||
"""
|
||||
import base64
|
||||
import httpx
|
||||
@@ -1225,32 +1263,38 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
prompt = str(args.get('prompt') or '').strip()
|
||||
model_spec = str(args.get('model') or '').strip()
|
||||
size = str(args.get('size') or '1024x1024')
|
||||
quality = str(args.get('quality') or 'medium')
|
||||
quality = str(args.get('quality') or '')
|
||||
else:
|
||||
lines = content.strip().split("\n")
|
||||
prompt = lines[0].strip() if lines else ""
|
||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
|
||||
|
||||
if not prompt:
|
||||
return {"error": "Image prompt is required (line 1)"}
|
||||
|
||||
# Load admin settings for defaults
|
||||
# Load settings for defaults
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
# The administrator's global image-generation disable switch is authoritative.
|
||||
# Non-admin preferences cannot re-enable generation when disabled globally.
|
||||
if not _settings.get("image_gen_enabled", True):
|
||||
return {"error": "Image generation is disabled by the administrator."}
|
||||
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
return {"error": "Image generation is disabled by user preference."}
|
||||
|
||||
# Use admin-configured model/quality if not specified by the tool call
|
||||
# Use user- or admin-configured model/quality if not specified by the tool call
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||
if not quality:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
|
||||
# Auto-detect best available image model if still not set
|
||||
if not model_spec:
|
||||
@@ -1342,8 +1386,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
|
||||
if detect_vendor(url) == "openrouter":
|
||||
images_url = base_url + "/images"
|
||||
if chat_images:
|
||||
images_url = base_url + "/chat/completions"
|
||||
|
||||
# Capture the requested geometry before older OpenAI size restrictions.
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
try:
|
||||
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
|
||||
requested_ratio = image_aspect_ratio(requested_width, requested_height)
|
||||
except (TypeError, ValueError):
|
||||
requested_ratio = "1:1"
|
||||
|
||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
@@ -1359,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
"n": 1,
|
||||
"size": size,
|
||||
}
|
||||
if detect_vendor(url) == "openrouter":
|
||||
payload.pop("size", None)
|
||||
payload["aspect_ratio"] = requested_ratio
|
||||
|
||||
# GPT image models and local diffusion support quality; DALL-E does not
|
||||
if is_gpt_image or is_local_diffusion:
|
||||
@@ -1372,7 +1430,16 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
# GPT image models can take 30-120s+ depending on quality
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||
resp = await client.post(images_url, json=payload, headers=headers)
|
||||
request_payload = payload
|
||||
if chat_images:
|
||||
request_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": requested_ratio},
|
||||
}
|
||||
resp = await client.post(images_url, json=request_payload, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
@@ -1387,9 +1454,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = _chat_image_results(data) if chat_images else data.get("data", [])
|
||||
if not images:
|
||||
return {"error": "No images returned from API"}
|
||||
return {"error": (
|
||||
f"Image model '{model_id}' returned no image. "
|
||||
"Choose another image generation model in AI settings."
|
||||
)}
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
@@ -1482,7 +1555,7 @@ async def do_edit_image(
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
size: str = "auto",
|
||||
quality: str = "medium",
|
||||
quality: Optional[str] = None,
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
incognito: bool = False,
|
||||
) -> Dict:
|
||||
@@ -1502,21 +1575,27 @@ async def do_edit_image(
|
||||
return {"error": "Attached image file was not found"}
|
||||
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
from src.settings import get_user_setting, load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality == "medium" and _settings.get("image_quality"):
|
||||
quality = _settings["image_quality"]
|
||||
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||
if quality is None:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
if not model_spec:
|
||||
return {"error": "No image model selected for image editing"}
|
||||
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1529,6 +1608,7 @@ async def do_edit_image(
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
requested_edit_size = size
|
||||
if not size or size == "auto":
|
||||
from PIL import Image
|
||||
from src.image_model_ids import image_edit_size
|
||||
@@ -1704,7 +1784,35 @@ async def do_edit_image(
|
||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||
}],
|
||||
}
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
from PIL import Image as ReferenceImage
|
||||
with ReferenceImage.open(path) as reference:
|
||||
reference_width, reference_height = reference.size
|
||||
if reference.getexif().get(274) in {5, 6, 7, 8}:
|
||||
reference_width, reference_height = reference_height, reference_width
|
||||
if requested_edit_size and requested_edit_size != "auto":
|
||||
try:
|
||||
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
edit_payload.pop("size", None)
|
||||
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
|
||||
if _uses_openrouter_chat_images(model_id):
|
||||
edit_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": f"data:{mime};base64,{image_b64}",
|
||||
}},
|
||||
]}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
|
||||
}
|
||||
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
|
||||
else:
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
else:
|
||||
with path.open("rb") as f:
|
||||
files = {"image": (path.name, f, mime)}
|
||||
@@ -1747,7 +1855,12 @@ async def do_edit_image(
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
images = data.get("data", [])
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = (_chat_image_results(data)
|
||||
if is_openrouter and _uses_openrouter_chat_images(model_id)
|
||||
else data.get("data", []))
|
||||
if not images:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
lines = [line for line in snapshot.splitlines()
|
||||
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
||||
elif url:
|
||||
notices.append('URL: ' + str(url))
|
||||
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
|
||||
parsed = json.loads(item)
|
||||
except (ValueError, TypeError):
|
||||
# CLI status text precedes the JSON post-interaction state.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
|
||||
# Labels are one line; stopping at \n keeps the split linear.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
|
||||
if len(parts) > 1:
|
||||
for part in parts:
|
||||
visit(part)
|
||||
|
||||
+37
-5
@@ -10,7 +10,7 @@ import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Tuple
|
||||
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
return str(e), False
|
||||
|
||||
|
||||
def _daily_brief_utc_boundary(local_dt, tz=None):
|
||||
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
||||
if local_dt.tzinfo is None and tz is not None:
|
||||
local_dt = local_dt.replace(tzinfo=tz)
|
||||
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _daily_brief_local_start(event, tz=None):
|
||||
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
||||
start = event.dtstart
|
||||
if event.is_utc and not event.all_day:
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=timezone.utc)
|
||||
return start.astimezone(tz).replace(tzinfo=None)
|
||||
return start
|
||||
|
||||
|
||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Build a short morning digest: today's calendar events, unread email count
|
||||
+ top-N senders/subjects, active todos."""
|
||||
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||
from routes.email_helpers import _imap_connect, _decode_header
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# ----- Calendar: today's events -----
|
||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow = today + _td(days=1)
|
||||
utc_today = _daily_brief_utc_boundary(today)
|
||||
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||
# user's daily brief must not include another user's notes or
|
||||
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
or_(
|
||||
and_(
|
||||
CalendarEvent.is_utc.is_(True),
|
||||
CalendarEvent.all_day.is_not(True),
|
||||
CalendarEvent.dtstart < utc_tomorrow,
|
||||
CalendarEvent.dtend > utc_today,
|
||||
),
|
||||
and_(
|
||||
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
),
|
||||
),
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
if owner:
|
||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
||||
events = ev_q.all()
|
||||
events.sort(key=_daily_brief_local_start)
|
||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||
if owner:
|
||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if events:
|
||||
plain.append("Calendar:")
|
||||
for e in events:
|
||||
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
||||
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
||||
loc = f" @ {e.location}" if e.location else ""
|
||||
plain.append(f" {t} {e.summary}{loc}")
|
||||
plain.append("")
|
||||
|
||||
+7
-1
@@ -282,7 +282,13 @@ class ChatHandler:
|
||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||
except Exception:
|
||||
pass
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
|
||||
if vl_desc:
|
||||
# A separate vision call wrote this, but the chat model sees
|
||||
# one message and answers the prose as if the user wrote it
|
||||
# (#5573). Label it below the marker line — the client strips
|
||||
# the block by matching `[Image: <name>]\n`.
|
||||
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
|
||||
# Surface the description to the client live so it renders as a
|
||||
# collapsible "image description" on the user bubble (not just
|
||||
# after a refresh that re-parses the stored message).
|
||||
|
||||
@@ -1229,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
|
||||
text = str(payload or '').strip()
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
|
||||
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
|
||||
if match:
|
||||
details = match[3].strip()
|
||||
rows.append({
|
||||
@@ -1346,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
|
||||
continue
|
||||
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
||||
if not match:
|
||||
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
|
||||
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
|
||||
if match and status:
|
||||
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
||||
if not rows:
|
||||
@@ -5065,7 +5065,7 @@ def email_search_result_empty(value):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
|
||||
if decoded == value:
|
||||
return False
|
||||
value = decoded
|
||||
@@ -5339,6 +5339,16 @@ def private_browser_effective_url(result):
|
||||
return urls[-1] if urls else ''
|
||||
|
||||
|
||||
def is_google_sorry_url(url):
|
||||
"""Recognize Google's rate-limit interstitial."""
|
||||
# Matching the host separately keeps this linear; one combined pattern
|
||||
# rescans the host from every dot.
|
||||
return any(
|
||||
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
|
||||
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
|
||||
)
|
||||
|
||||
|
||||
def browser_observation_page_missing(raw):
|
||||
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
||||
text = str(raw or '')
|
||||
@@ -8213,11 +8223,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
browser_search_url = requested_browser_url or effective_browser_url
|
||||
is_search_engine_navigation = contains_search_engine_navigation(
|
||||
browser_search_url
|
||||
) or bool(re.search(
|
||||
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
|
||||
effective_browser_url,
|
||||
re.I,
|
||||
))
|
||||
) or is_google_sorry_url(effective_browser_url)
|
||||
has_native_search = any(
|
||||
canonical(schema['function']['name']) == 'web_search'
|
||||
for schema in offered
|
||||
|
||||
@@ -31,63 +31,43 @@ def _internal_headers() -> dict:
|
||||
|
||||
|
||||
async def _delete_endpoint_for_task(task: dict) -> None:
|
||||
"""Drop the auto-registered model endpoint for a scheduled-stop serve.
|
||||
"""Remove a stopped serve's endpoint using the normal deletion contract."""
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
Without this, killing the tmux session leaves the endpoint sitting in
|
||||
the picker (probe goes offline; chats still try to route there) and
|
||||
the user has to delete it by hand in Settings -> Endpoints.
|
||||
"""
|
||||
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||
if not endpoint_id:
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
|
||||
task.get("sessionId") or task.get("id") or "",
|
||||
)
|
||||
endpoint_id = str(task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||
serve_session_id = str(task.get("sessionId") or task.get("id") or "").strip()
|
||||
if (
|
||||
not re.fullmatch(r"(?:local|img)-[0-9a-f]{8}", endpoint_id)
|
||||
or not serve_session_id
|
||||
):
|
||||
logger.info("cookbook_serve_lifecycle: missing endpoint/session identity")
|
||||
return
|
||||
import re as _re
|
||||
payload = task.get("payload") or {}
|
||||
cmd = str(payload.get("_cmd") or "")
|
||||
remote = task.get("remoteHost") or ""
|
||||
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "host.docker.internal"
|
||||
port_match = _re.search(r"--port\s+(\d+)", cmd)
|
||||
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
r = await client.get(
|
||||
f"{internal_api_base()}/api/model-endpoints",
|
||||
response = await client.delete(
|
||||
f"{internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||
params={"expected_cookbook_serve_session_id": serve_session_id},
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
return
|
||||
eps = r.json() if r.content else []
|
||||
# Delete only the endpoint created by this scheduled serve. URL
|
||||
# matching is unsafe because a later scheduled serve can reuse the
|
||||
# same host:port after an older task has gone stale.
|
||||
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
|
||||
if ep:
|
||||
await client.delete(
|
||||
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
logger.info(
|
||||
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} "
|
||||
f"({ep.get('base_url')}) after scheduled stop"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}")
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: deleted endpoint %s for serve %s",
|
||||
endpoint_id, serve_session_id,
|
||||
)
|
||||
elif response.status_code in (404, 409):
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: endpoint %s absent or reassigned; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"cookbook_serve_lifecycle: endpoint %s deletion returned HTTP %s",
|
||||
endpoint_id, response.status_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("cookbook_serve_lifecycle: cleanup failed: %s", exc)
|
||||
|
||||
|
||||
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
||||
|
||||
@@ -142,7 +142,10 @@ def _process_pdf(
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
temp_img_path = tmp.name
|
||||
try:
|
||||
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
|
||||
image = img.image
|
||||
if image.mode == "CMYK":
|
||||
image = image.convert("RGB")
|
||||
image.save(temp_img_path, "PNG")
|
||||
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
||||
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""The shared model defaults used by Quick Edit's image operations."""
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
|
||||
def editor_image_models(owner: str = "") -> dict[str, str]:
|
||||
settings = load_settings()
|
||||
generation = ""
|
||||
edit_model = ""
|
||||
if owner:
|
||||
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
|
||||
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
|
||||
if not generation:
|
||||
generation = str(settings.get("image_model") or "").strip()
|
||||
if not edit_model:
|
||||
edit_model = str(settings.get("image_edit_model") or generation).strip()
|
||||
return {
|
||||
"generation": generation,
|
||||
"edit": edit_model or generation,
|
||||
}
|
||||
@@ -40,6 +40,15 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def image_aspect_ratio(width: int, height: int) -> str:
|
||||
"""Choose the nearest common Image API ratio, without distorting pixels."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Image dimensions must be positive")
|
||||
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
|
||||
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
|
||||
return f"{w}:{h}"
|
||||
|
||||
|
||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
|
||||
+49
-11
@@ -422,7 +422,18 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
_WORD_CHARS = r"A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff"
|
||||
_DELIM_RE = re.compile(rf"[^{_WORD_CHARS}+\-.]")
|
||||
_NUMERIC_PATTERN = rf"(?<![{_WORD_CHARS}])[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?(?![{_WORD_CHARS}])"
|
||||
_NUMERIC_FULLMATCH_RE = re.compile(rf"^[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?$")
|
||||
_DEGENERATE_WORD_RE = re.compile(rf"[{_WORD_CHARS}]+")
|
||||
_TOKEN_RE = re.compile(rf"(?P<NUM>{_NUMERIC_PATTERN})|(?P<WORD>[{_WORD_CHARS}]+)")
|
||||
|
||||
|
||||
def _is_numeric_token(token: str) -> bool:
|
||||
if token.isdigit():
|
||||
return True
|
||||
return _NUMERIC_FULLMATCH_RE.fullmatch(token) is not None
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
@@ -432,32 +443,50 @@ class _DegenerateStreamGuard:
|
||||
"Summer Summer ..."). This is not a useful response and can burn context,
|
||||
browser memory, and GPU time. Keep the guard conservative: only fire on long
|
||||
same-token runs or a very dominant repeated token in the recent window.
|
||||
Repetition made only of numbers is data (a zero or identity matrix, a
|
||||
padded buffer), so it only stops at a long all-numeric backstop.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str):
|
||||
self.model = model or "model"
|
||||
self.last_token = ""
|
||||
self.same_run = 0
|
||||
self.numeric_run = 0
|
||||
self.recent_tokens: List[str] = []
|
||||
self.periodic_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
self._buffer = ""
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
# Keep one-character tokens. Structured output often varies through
|
||||
# numeric selectors or coordinates; dropping those tokens can make
|
||||
# valid CSS/SVG rows look like an exact repeated phrase.
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
|
||||
self._buffer += text
|
||||
|
||||
m_delims = list(_DELIM_RE.finditer(self._buffer))
|
||||
if m_delims:
|
||||
split_idx = m_delims[-1].end()
|
||||
head = self._buffer[:split_idx]
|
||||
self._buffer = self._buffer[split_idx:]
|
||||
elif len(self._buffer) >= 256:
|
||||
head = self._buffer
|
||||
self._buffer = ""
|
||||
else:
|
||||
return None
|
||||
|
||||
tokens = []
|
||||
for m in _TOKEN_RE.finditer(head):
|
||||
tokens.append((m.group(0).lower(), m.lastgroup == "NUM"))
|
||||
if not tokens:
|
||||
return None
|
||||
for token in tokens:
|
||||
|
||||
for token, is_num in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.numeric_run = self.numeric_run + 1 if is_num else 0
|
||||
self.recent_tokens.append(token)
|
||||
self.periodic_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
@@ -466,14 +495,20 @@ class _DegenerateStreamGuard:
|
||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100:
|
||||
if self.same_run >= 28 and self.total_chars >= 100 and not _is_numeric_token(self.last_token):
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not _is_numeric_token(top):
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if not reason and len(self.recent_tokens) >= 48:
|
||||
if (
|
||||
not reason
|
||||
and len(self.recent_tokens) >= 48
|
||||
# A window that is mostly numbers is matrix/table data; its
|
||||
# periodicity is real. Mixed loops still hit the exact-block check.
|
||||
and sum(_is_numeric_token(t) for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||
):
|
||||
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||
# generation repeats nearly every token at a short fixed period.
|
||||
@@ -508,12 +543,15 @@ class _DegenerateStreamGuard:
|
||||
if history[previous:previous + anchor_size] != anchor:
|
||||
continue
|
||||
period = current_anchor - previous
|
||||
if period < 24 or len(history) < period * 2:
|
||||
if period < 24 or len(history) < period * 2 or self.numeric_run >= period:
|
||||
continue
|
||||
if history[-2 * period:-period] == history[-period:]:
|
||||
if history[-2 * period:-period] == history[-period:] and not all(_is_numeric_token(t) for t in history[-period:]):
|
||||
reason = f"repeated an exact {period}-token block twice"
|
||||
break
|
||||
|
||||
if not reason and self.numeric_run >= 2048:
|
||||
reason = f"emitted {self.numeric_run} numbers in a row"
|
||||
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
|
||||
+4
-1
@@ -770,8 +770,11 @@ class McpManager:
|
||||
qualified.add(f"mcp__{server_id}__{tool['name']}")
|
||||
return disabled_map, qualified
|
||||
|
||||
def is_builtin(self, server_id: str) -> bool:
|
||||
@staticmethod
|
||||
def is_builtin(server_id: str) -> bool:
|
||||
"""Check if a server is a built-in (auto-registered) server."""
|
||||
if not isinstance(server_id, str):
|
||||
return False
|
||||
return server_id.startswith("builtin_") or server_id in {
|
||||
"image_gen",
|
||||
"memory",
|
||||
|
||||
@@ -193,7 +193,10 @@ class ModelDiscovery:
|
||||
data = r.json()
|
||||
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
|
||||
items = data if isinstance(data, list) else ((data or {}).get("data") or [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
ids = [
|
||||
m["id"] for m in items
|
||||
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
|
||||
]
|
||||
if ids:
|
||||
return {
|
||||
"host": host,
|
||||
|
||||
+4
-2
@@ -102,8 +102,10 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
|
||||
# - **label:** value <!-- field=NAME-ENC type=text -->
|
||||
# - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
|
||||
# - [x] **label** <!-- field=NAME-ENC type=checkbox -->
|
||||
# Accept Unicode word characters for older saved markers, whose encoder left
|
||||
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
|
||||
_FIELD_BULLET_RE = re.compile(
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
|
||||
)
|
||||
|
||||
|
||||
@@ -115,7 +117,7 @@ def _encode_name(name: str) -> str:
|
||||
"""
|
||||
out = []
|
||||
for ch in name or "":
|
||||
if ch.isalnum() or ch in ("_", ".", "-"):
|
||||
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"):
|
||||
out.append(ch)
|
||||
else:
|
||||
for b in ch.encode("utf-8"):
|
||||
|
||||
+63
-8
@@ -76,9 +76,23 @@ _ERROR_DETAIL = {
|
||||
"auth_or_protocol_error": "authentication or protocol error",
|
||||
"no_models": "endpoint returned no models",
|
||||
"no_host": "no host configured",
|
||||
"config_source_error": "configuration source could not be read",
|
||||
"error": "probe failed",
|
||||
}
|
||||
|
||||
# Config/inventory source behind each network subsystem. `_gather_inputs()`
|
||||
# fails soft, so a source that could not be read hands its probe an empty
|
||||
# inventory — indistinguishable from "nothing configured", which every probe
|
||||
# answers with `disabled`. The collector therefore reports the source failure
|
||||
# instead of running a probe whose verdict would be a lie.
|
||||
CONFIG_SOURCE_ERROR = "config_source_error"
|
||||
_SUBSYSTEM_SOURCE = {
|
||||
"searxng": "settings",
|
||||
"ntfy": "integrations",
|
||||
"email": "accounts",
|
||||
"providers": "endpoints",
|
||||
}
|
||||
|
||||
|
||||
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
||||
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
||||
@@ -411,26 +425,33 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
"""Pull live config/account/endpoint lists from the app's data sources.
|
||||
|
||||
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
||||
single failure can't take down the whole health report.
|
||||
single failure can't take down the whole health report. The failure is also
|
||||
*recorded* under `failed` (source -> controlled category), because the empty
|
||||
value alone cannot be told apart from a source that loaded and is genuinely
|
||||
empty — and the probes read empty as `disabled`.
|
||||
"""
|
||||
settings: Dict[str, Any] = {}
|
||||
integrations: List[Dict[str, Any]] = []
|
||||
accounts: List[Dict[str, Any]] = []
|
||||
endpoints: List[Dict[str, Any]] = []
|
||||
failed: Dict[str, str] = {}
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
settings = load_settings() or {}
|
||||
except Exception as e:
|
||||
failed["settings"] = _classify_error(e)
|
||||
logger.debug(f"service_health: settings load failed: {e}")
|
||||
try:
|
||||
from src.integrations import load_integrations
|
||||
integrations = load_integrations() or []
|
||||
except Exception as e:
|
||||
failed["integrations"] = _classify_error(e)
|
||||
logger.debug(f"service_health: integrations load failed: {e}")
|
||||
try:
|
||||
from routes.email_helpers import _list_email_accounts
|
||||
accounts = _list_email_accounts() or []
|
||||
except Exception as e:
|
||||
failed["accounts"] = _classify_error(e)
|
||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
@@ -443,9 +464,10 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
failed["endpoints"] = _classify_error(e)
|
||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||
return {"settings": settings, "integrations": integrations,
|
||||
"accounts": accounts, "endpoints": endpoints}
|
||||
"accounts": accounts, "endpoints": endpoints, "failed": failed}
|
||||
|
||||
|
||||
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
@@ -464,6 +486,24 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
return _svc(name, DOWN, _detail_for(category), error=category)
|
||||
|
||||
|
||||
async def _source_unavailable(name: str, source: str,
|
||||
category: str) -> Dict[str, Any]:
|
||||
"""Controlled entry for a subsystem whose config source could not be read.
|
||||
|
||||
Deliberately not `disabled`: the configured state is *unknown*, not known to
|
||||
be off. `degraded` rather than `down` because the failure is in reading the
|
||||
inventory, not a probe that reached the subsystem and failed — but it still
|
||||
carries non-zero severity, so a report containing one can never roll up to
|
||||
`ok`. `meta` names the source and the controlled category of the underlying
|
||||
exception; never its text.
|
||||
"""
|
||||
return _svc(name, DEGRADED,
|
||||
f"Could not read configuration "
|
||||
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
|
||||
error=CONFIG_SOURCE_ERROR, source=source,
|
||||
source_error=category)
|
||||
|
||||
|
||||
async def collect_service_health(rag_manager: Any = None,
|
||||
memory_vector: Any = None) -> Dict[str, Any]:
|
||||
"""Run every probe and return {overall, services, timestamp}.
|
||||
@@ -472,6 +512,10 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
||||
|
||||
A subsystem whose config/inventory source failed to load is reported via
|
||||
`_source_unavailable()` instead of being probed with an empty inventory,
|
||||
which every probe would otherwise report as `disabled`.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -482,12 +526,23 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
chroma = chromadb_health(rag_manager, memory_vector)
|
||||
|
||||
names = ["searxng", "ntfy", "email", "providers"]
|
||||
coros = [
|
||||
_run_subsystem("searxng", searxng_health, settings),
|
||||
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
||||
_run_subsystem("email", email_health, inputs["accounts"]),
|
||||
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
||||
]
|
||||
probes = {
|
||||
"searxng": (searxng_health, (settings,)),
|
||||
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
||||
"email": (email_health, (inputs["accounts"],)),
|
||||
"providers": (providers_health, (inputs["endpoints"],)),
|
||||
}
|
||||
# A subsystem whose inventory could not be read is reported as such. The
|
||||
# others are still probed normally — one broken source degrades one entry.
|
||||
failed = inputs.get("failed") or {}
|
||||
coros = []
|
||||
for name in names:
|
||||
source = _SUBSYSTEM_SOURCE[name]
|
||||
if source in failed:
|
||||
coros.append(_source_unavailable(name, source, failed[source]))
|
||||
else:
|
||||
fn, args = probes[name]
|
||||
coros.append(_run_subsystem(name, fn, *args))
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||
timeout=_AGGREGATE_DEADLINE)
|
||||
|
||||
+33
-5
@@ -57,25 +57,53 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
def escape_like_pattern(value: str) -> str:
|
||||
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
|
||||
|
||||
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
|
||||
patterns from user input. Must escape backslash first.
|
||||
"""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return escape_like_pattern(value)
|
||||
|
||||
|
||||
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||
content = content or ""
|
||||
query = query or ""
|
||||
if not query:
|
||||
return content[: radius * 2]
|
||||
|
||||
idx = content.lower().find(query.lower())
|
||||
if idx == -1:
|
||||
match_start = -1
|
||||
match_length = 0
|
||||
lowered_content = content.lower()
|
||||
for candidate in _query_terms(query):
|
||||
idx = lowered_content.find(candidate.lower())
|
||||
if idx != -1 and (match_start == -1 or idx < match_start):
|
||||
match_start = idx
|
||||
match_length = len(candidate)
|
||||
|
||||
if match_start == -1:
|
||||
return content[: radius * 2]
|
||||
|
||||
start = max(0, idx - radius)
|
||||
end = min(len(content), idx + len(query) + radius)
|
||||
start = max(0, match_start - radius)
|
||||
end = min(len(content), match_start + match_length + radius)
|
||||
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
||||
|
||||
|
||||
def _query_terms(query: str) -> list[str]:
|
||||
"""Return meaningful quoted phrases and tokens in user-entered order."""
|
||||
terms: list[str] = []
|
||||
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
|
||||
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
|
||||
term = term.strip()
|
||||
if term:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
def _sanitize_fts_query(query: str) -> str | None:
|
||||
"""Convert free text into a conservative FTS5 MATCH query.
|
||||
|
||||
|
||||
+16
-1
@@ -46,6 +46,7 @@ DEFAULT_SETTINGS = {
|
||||
"agent_email_confirm": True,
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_edit_model": "",
|
||||
"image_quality": "medium",
|
||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||
# fallback without probing a developer-specific host.
|
||||
@@ -294,7 +295,7 @@ def is_setting_overridden(key: str) -> bool:
|
||||
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||
_PER_USER_KEYS = {
|
||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
||||
"image_model", "image_gen_enabled", "image_quality",
|
||||
"image_model", "image_edit_model", "image_gen_enabled", "image_quality",
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
@@ -313,6 +314,20 @@ def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
|
||||
Falls back gracefully if the prefs module can't be imported (cycle/early
|
||||
boot) — admin-global settings keep working.
|
||||
"""
|
||||
if key == "image_gen_enabled":
|
||||
global_val = get_setting("image_gen_enabled", default if default is not None else False)
|
||||
if not global_val:
|
||||
return False
|
||||
if owner:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
prefs = _load_for_user(owner) or {}
|
||||
if "image_gen_enabled" in prefs and prefs["image_gen_enabled"] is not None:
|
||||
return bool(prefs["image_gen_enabled"])
|
||||
except Exception:
|
||||
pass
|
||||
return bool(global_val)
|
||||
|
||||
if owner and key in _PER_USER_KEYS:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
|
||||
+22
-2
@@ -132,6 +132,23 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
|
||||
_shared_cache_pending.pop(key, None)
|
||||
|
||||
|
||||
def is_valid_cron(cron_expression: str) -> bool:
|
||||
"""True for a standard 5-field cron expression that croniter can parse.
|
||||
|
||||
croniter also accepts 6- and 7-field expressions with a seconds field, but
|
||||
the scheduler works at one-minute resolution and would run such a task
|
||||
every second.
|
||||
"""
|
||||
if len((cron_expression or "").split()) != 5:
|
||||
return False
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(cron_expression)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compute_next_run(schedule: str, scheduled_time: str,
|
||||
scheduled_day: int = None,
|
||||
scheduled_date: datetime = None,
|
||||
@@ -175,6 +192,9 @@ def compute_next_run(schedule: str, scheduled_time: str,
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if schedule == "cron" and cron_expression:
|
||||
if not is_valid_cron(cron_expression):
|
||||
logger.warning(f"Invalid cron expression '{cron_expression}'")
|
||||
return None
|
||||
try:
|
||||
from croniter import croniter
|
||||
cron = croniter(cron_expression, now)
|
||||
@@ -306,7 +326,7 @@ def _digest_windows(now):
|
||||
|
||||
|
||||
def _checkin_calendar_events(db, owner, start, end):
|
||||
"""Calendar events in [start, end] for ONE owner, for the check-in digest.
|
||||
"""Calendar events in [start, end) for ONE owner, for the check-in digest.
|
||||
|
||||
Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
|
||||
The digest query had no owner scope, so it pulled EVERY user's events into
|
||||
@@ -320,7 +340,7 @@ def _checkin_calendar_events(db, owner, start, end):
|
||||
.filter(
|
||||
_CC.owner == owner,
|
||||
_CE.dtstart >= start,
|
||||
_CE.dtstart <= end,
|
||||
_CE.dtstart < end,
|
||||
_CE.status != "cancelled",
|
||||
)
|
||||
.order_by(_CE.dtstart)
|
||||
|
||||
@@ -334,6 +334,142 @@ _BROWSER_MCP_READ_TOOLS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
LOCAL_MCP_READ_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_READ_TOOLS"
|
||||
LOCAL_MCP_WRITE_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_WRITE_TOOLS"
|
||||
|
||||
_LOCAL_MCP_READ_CAPABILITIES = _capabilities(
|
||||
ToolEffect.READ_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_LOCAL_MCP_WRITE_CAPABILITIES = _capabilities(
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
|
||||
|
||||
def _local_mcp_tools(variable: str) -> frozenset[str]:
|
||||
"""Tool names a deployment declares as touching only the local store.
|
||||
|
||||
Tools served by a user-configured MCP server are absent from the registry
|
||||
above, so they are classified as unknown and fail high. For a server with
|
||||
both a read and a write tool that is unusable: the read result arms the
|
||||
post-external gate, because an unknown result is not SYSTEM integrity, and
|
||||
the write that follows is refused until separately authorized. Every
|
||||
second call needs an approval, for a server that never leaves the host.
|
||||
|
||||
Declaring the tools resolves that without widening the gate:
|
||||
|
||||
ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__<server>__search_notes,mcp__<server>__read_note
|
||||
ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__<server>__create_note
|
||||
|
||||
Read tools become READ_PRIVATE with EXTERNAL_UNTRUSTED integrity. Their
|
||||
results remain untrusted and still arm the gate, preventing prompt
|
||||
injections from reaching shell, network, or unrelated tools. However,
|
||||
declared write tools on the exact same MCP server receive a narrowly
|
||||
scoped continuation exception as long as no external or error taint has
|
||||
entered the run. Once genuinely external content has entered the run,
|
||||
approvals remain required.
|
||||
"""
|
||||
raw = os.getenv(variable, "") or ""
|
||||
return frozenset(name.strip() for name in raw.split(",") if name.strip())
|
||||
|
||||
|
||||
def _current_mcp_manager():
|
||||
"""Retrieve the current McpManager instance safely without hard circular binds."""
|
||||
try:
|
||||
from src import tool_execution
|
||||
if hasattr(tool_execution, "get_mcp_manager"):
|
||||
mgr = tool_execution.get_mcp_manager()
|
||||
if mgr is not None:
|
||||
return mgr
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from src.tool_utils import get_mcp_manager
|
||||
return get_mcp_manager()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_declared_local_mcp_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
"""Return whether tool_name is an exact, well-formed declared local MCP tool."""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
if not (
|
||||
len(parts) == 3
|
||||
and parts[0] == "mcp"
|
||||
and bool(parts[1])
|
||||
and bool(parts[2])
|
||||
and not any(char.isspace() for part in parts[1:] for char in part)
|
||||
):
|
||||
return False
|
||||
sid = parts[1]
|
||||
if server_id is not None and sid != server_id:
|
||||
return False
|
||||
from src.mcp_manager import McpManager
|
||||
if McpManager.is_builtin(sid):
|
||||
return False
|
||||
if tool_name in TOOL_CAPABILITIES:
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
return (declared_read or declared_write) and (declared_read != declared_write)
|
||||
|
||||
|
||||
def _is_declared_local_mcp_read_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id):
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
if not (declared_read and not declared_write):
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
mgr = _current_mcp_manager()
|
||||
if mgr is not None:
|
||||
tools = mgr._tools.get(parts[1], [])
|
||||
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
|
||||
if tool_row is not None:
|
||||
from src.mcp_manager import mcp_tool_is_readonly
|
||||
if not mcp_tool_is_readonly(tool_row):
|
||||
return False
|
||||
else:
|
||||
from src.mcp_manager import _MCP_READONLY_VERBS
|
||||
if not parts[2].lower().startswith(_MCP_READONLY_VERBS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_declared_local_mcp_write_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id):
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
if not (declared_write and not declared_read):
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
mgr = _current_mcp_manager()
|
||||
if mgr is not None:
|
||||
tools = mgr._tools.get(parts[1], [])
|
||||
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
|
||||
if tool_row is not None:
|
||||
ann = tool_row.get("annotations") or {}
|
||||
destructive = (
|
||||
ann.get("destructiveHint")
|
||||
if isinstance(ann, dict)
|
||||
else getattr(ann, "destructiveHint", None)
|
||||
)
|
||||
if destructive is True:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||
"""Return deterministic capabilities; malformed and unknown tools fail high."""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
@@ -348,6 +484,10 @@ def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||
return capabilities
|
||||
if tool_name in _BROWSER_MCP_READ_TOOLS:
|
||||
return _BROWSER_MCP_READ_CAPABILITIES
|
||||
if _is_declared_local_mcp_read_tool(tool_name):
|
||||
return _LOCAL_MCP_READ_CAPABILITIES
|
||||
if _is_declared_local_mcp_write_tool(tool_name):
|
||||
return _LOCAL_MCP_WRITE_CAPABILITIES
|
||||
return _UNKNOWN_CAPABILITIES
|
||||
|
||||
|
||||
@@ -785,6 +925,149 @@ def delegated_tool_is_blocked(tool_name: Any, content: Any = None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
_SERVER_OWNED_CONTROL_PLANE_SOURCES = frozenset(
|
||||
{
|
||||
"skills",
|
||||
"client runtime context",
|
||||
"backend runtime context",
|
||||
"integrations",
|
||||
"mcp tools",
|
||||
"agents.md",
|
||||
}
|
||||
)
|
||||
|
||||
_PROHIBITED_CONTINUATION_EFFECTS = frozenset(
|
||||
{
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||
ToolEffect.EXECUTE_CODE,
|
||||
ToolEffect.ADMIN_CHANGE,
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
ToolEffect.DESTRUCTIVE,
|
||||
ToolEffect.READ_WORKSPACE,
|
||||
ToolEffect.WRITE_WORKSPACE,
|
||||
ToolEffect.BROKERED_NETWORK_READ,
|
||||
ToolEffect.READ_PUBLIC,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_authorized_local_mcp_continuation(
|
||||
tool_name: Any,
|
||||
external_sources: Iterable[str],
|
||||
content: Any = None,
|
||||
) -> bool:
|
||||
"""Evaluate narrowly scoped continuation for declared local MCP tools.
|
||||
|
||||
Allows a declared local MCP write or read tool when the tool is verified
|
||||
against an explicitly configured and connected stdio MCP server, and the
|
||||
only untrusted sources observed in the run are declared local MCP tools
|
||||
belonging to the exact same MCP server (plus server-owned control plane
|
||||
metadata).
|
||||
"""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
if len(parts) != 3 or parts[0] != "mcp" or not parts[1] or not parts[2]:
|
||||
return False
|
||||
server_id = parts[1]
|
||||
|
||||
from src.mcp_manager import McpManager
|
||||
if McpManager.is_builtin(server_id):
|
||||
return False
|
||||
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id=server_id):
|
||||
return False
|
||||
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
if not capabilities.known:
|
||||
return False
|
||||
if capabilities.effects & _PROHIBITED_CONTINUATION_EFFECTS:
|
||||
return False
|
||||
if capabilities.effects - {ToolEffect.WRITE_PRIVATE, ToolEffect.READ_PRIVATE}:
|
||||
return False
|
||||
|
||||
manager = _current_mcp_manager()
|
||||
if manager is None:
|
||||
return False
|
||||
if manager.is_builtin(server_id):
|
||||
return False
|
||||
|
||||
conn = manager.get_server_status(server_id)
|
||||
if conn.get("status") != "connected":
|
||||
return False
|
||||
if conn.get("transport") != "stdio":
|
||||
return False
|
||||
|
||||
resource_id = manager.resource_identity(tool_name)
|
||||
if resource_id is None or resource_id.namespace != "mcp":
|
||||
return False
|
||||
if resource_id.endpoint_id != f"stdio:{server_id}":
|
||||
return False
|
||||
|
||||
tools_list = manager._tools.get(server_id, [])
|
||||
tool_row = next((r for r in tools_list if r.get("name") == parts[2]), None)
|
||||
if not tool_row:
|
||||
return False
|
||||
ann = tool_row.get("annotations") or {}
|
||||
destructive = (
|
||||
ann.get("destructiveHint")
|
||||
if isinstance(ann, dict)
|
||||
else getattr(ann, "destructiveHint", None)
|
||||
)
|
||||
if destructive is True:
|
||||
return False
|
||||
|
||||
from src.agent_runtime.authority import active_request_authority, ExactOperation, RequestAuthority
|
||||
authority = active_request_authority()
|
||||
if not isinstance(authority, RequestAuthority):
|
||||
return False
|
||||
if authority is not None:
|
||||
try:
|
||||
operation_input = content if isinstance(content, str) else "{}"
|
||||
operation = ExactOperation.normalize(tool_name, operation_input)
|
||||
if not authority.permits(operation):
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if resource_id not in authority.backend_resources:
|
||||
return False
|
||||
|
||||
sources = list(external_sources or ())
|
||||
if not sources:
|
||||
return False
|
||||
|
||||
from src.mcp_manager import mcp_tool_is_readonly
|
||||
has_local_tool = False
|
||||
for source in sources:
|
||||
if source in _SERVER_OWNED_CONTROL_PLANE_SOURCES:
|
||||
continue
|
||||
if not _is_declared_local_mcp_tool(source, server_id=server_id):
|
||||
return False
|
||||
source_caps = capabilities_for_tool(source)
|
||||
if not source_caps.known or (source_caps.effects & _PROHIBITED_CONTINUATION_EFFECTS):
|
||||
return False
|
||||
if source_caps.effects - {ToolEffect.READ_PRIVATE, ToolEffect.WRITE_PRIVATE}:
|
||||
return False
|
||||
|
||||
source_resource = manager.resource_identity(source)
|
||||
if source_resource is None or source_resource.endpoint_id != resource_id.endpoint_id:
|
||||
return False
|
||||
if not str(source_resource.endpoint_id).startswith("stdio:"):
|
||||
return False
|
||||
|
||||
source_parts = source.split("__", 2)
|
||||
source_row = next((r for r in tools_list if r.get("name") == source_parts[2]), None)
|
||||
if not source_row:
|
||||
return False
|
||||
if _is_declared_local_mcp_read_tool(source, server_id=server_id) and not mcp_tool_is_readonly(source_row):
|
||||
return False
|
||||
|
||||
has_local_tool = True
|
||||
|
||||
return has_local_tool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRunSecurityContext:
|
||||
"""Server-owned integrity state for one agent run."""
|
||||
@@ -804,6 +1087,19 @@ class ToolRunSecurityContext:
|
||||
# Driven by a bearer API token, not a person at a browser. Privileged
|
||||
# tools are refused outright and no approval can lift that.
|
||||
delegated_credential: bool = False
|
||||
explicit_untrusted_seen: bool = False
|
||||
prompt_external_untrusted_seen: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.external_untrusted_context_seen and (
|
||||
not self.external_sources
|
||||
or any(
|
||||
not _is_declared_local_mcp_tool(s)
|
||||
and s not in _SERVER_OWNED_CONTROL_PLANE_SOURCES
|
||||
for s in self.external_sources
|
||||
)
|
||||
):
|
||||
self.prompt_external_untrusted_seen = True
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
@@ -834,8 +1130,10 @@ class ToolRunSecurityContext:
|
||||
if source and source not in self.external_sources:
|
||||
self.external_sources.append(source)
|
||||
self.external_untrusted_context_seen = True
|
||||
self.prompt_external_untrusted_seen = True
|
||||
if messages_contain_external_untrusted_context(message_list):
|
||||
self.external_untrusted_context_seen = True
|
||||
self.prompt_external_untrusted_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
# Checked before the bypasses below, because neither may lift it, and
|
||||
@@ -858,6 +1156,20 @@ class ToolRunSecurityContext:
|
||||
if not self.external_untrusted_context_seen:
|
||||
return ToolGateDecision(True)
|
||||
|
||||
# Scoped continuation exception for explicitly declared local MCP tools:
|
||||
# A declared write or read tool on the same MCP server is allowed if
|
||||
# the run's only untrusted context was introduced by declared local
|
||||
# tools on that server, without any explicit/error taint or external
|
||||
# message context.
|
||||
if (
|
||||
not self.prompt_external_untrusted_seen
|
||||
and not self.explicit_untrusted_seen
|
||||
and _is_authorized_local_mcp_continuation(
|
||||
tool_name, self.external_sources, content=content
|
||||
)
|
||||
):
|
||||
return ToolGateDecision(True)
|
||||
|
||||
# Skills and other server-owned descriptors are control-plane metadata
|
||||
# rather than external result content; web/document/tool-result taint
|
||||
# still gates actions that can mutate state or execute code.
|
||||
@@ -906,6 +1218,11 @@ class ToolRunSecurityContext:
|
||||
if not tool_result_should_arm_gate(tool_name, result, content):
|
||||
return
|
||||
self.external_untrusted_context_seen = True
|
||||
if isinstance(result, dict) and (
|
||||
result.get("untrusted_content") is True
|
||||
or not tool_result_is_successful(result)
|
||||
):
|
||||
self.explicit_untrusted_seen = True
|
||||
if isinstance(tool_name, str) and tool_name not in self.external_sources:
|
||||
self.external_sources.append(tool_name)
|
||||
|
||||
|
||||
+11
-4
@@ -1917,10 +1917,17 @@ async def execute_tool_block(
|
||||
)
|
||||
|
||||
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
if isinstance(authority, RequestAuthority):
|
||||
with bind_request_authority(authority):
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
else:
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
if not decision.allowed:
|
||||
logger.warning(
|
||||
"External-context policy blocked tool=%r",
|
||||
|
||||
@@ -41,6 +41,7 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
|
||||
"write_file": ("path",),
|
||||
"edit_file": ("path",),
|
||||
"apply_patch": ("patch_text", "patchText", "patch"),
|
||||
"generate_image": ("prompt",),
|
||||
"host_shell": ("command",),
|
||||
}
|
||||
|
||||
|
||||
@@ -6440,6 +6440,12 @@ def resolve_turn_contract(*, capabilities: Iterable[str], schemas: Iterable[dict
|
||||
if 'search_emails' in selected:
|
||||
selected.update({'read_email', 'download_attachment', 'list_email_accounts'})
|
||||
selected.update(canonical_tool(n) for n in always_available_tools)
|
||||
# Offer only dynamic MCP operations already sealed by trusted request
|
||||
# admission. Tool discovery and model routing never create these grants.
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
sealed_authority = active_request_authority()
|
||||
if sealed_authority is not None:
|
||||
selected.update(g.tool for g in sealed_authority.grants if g.tool.startswith("mcp__"))
|
||||
selected.update(canonical_tool(n) for n in warm_tools if str(n or "").strip())
|
||||
# Controls are neutral; enabling Web is permission, never a requested family.
|
||||
if selected:
|
||||
|
||||
@@ -4592,7 +4592,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
width:100%; background:var(--bg); color:var(--fg);
|
||||
border:1px solid var(--border); border-radius:6px;
|
||||
padding:8px; font-family:inherit; font-size:0.95rem;
|
||||
resize:vertical; outline:none;
|
||||
resize:both; outline:none;
|
||||
field-sizing:content;
|
||||
max-width:100%; box-sizing:border-box;
|
||||
min-height:80px;
|
||||
}
|
||||
.edit-textarea:focus { border-color:var(--red); }
|
||||
|
||||
@@ -52,6 +52,45 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-user-name { font-size: 13px; font-weight: 500; }
|
||||
/* Inline endpoint rename: the input keeps the name's typography so the row doesn't shift */
|
||||
.adm-ep-rename-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 2px;
|
||||
color: inherit;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.adm-ep-rename-btn:hover,
|
||||
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
|
||||
@media (hover: hover) {
|
||||
.adm-ep-rename-btn { opacity: 0; }
|
||||
.admin-user-row:hover .adm-ep-rename-btn { opacity: 0.45; }
|
||||
.admin-user-row .adm-ep-rename-btn:hover,
|
||||
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
|
||||
}
|
||||
.admin-user-row input.adm-ep-rename-input {
|
||||
width: auto;
|
||||
field-sizing: content;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
padding: 0 0 1px;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--red);
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
}
|
||||
.admin-user-info:has(.adm-ep-name, .adm-ep-rename-input) { min-width: 0; }
|
||||
.adm-ep-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Privilege panel */
|
||||
.admin-priv-panel {
|
||||
max-height: 600px;
|
||||
|
||||
@@ -264,7 +264,42 @@
|
||||
/* The brush controls (Color/Size/preset/caret) hug their content; only the
|
||||
trailing slider section grows, so the Opacity/Flow/Softness group sits
|
||||
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; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.ge-controls--in-options #ge-brush-softness-row input[type="range"] { width: 88px; flex: 0 0 88px; }
|
||||
#ge-brush-softness-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: 14px max-content 88px;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
#ge-brush-softness-row:has(> .ge-slider-value) {
|
||||
grid-template-columns: 14px max-content 88px 6ch;
|
||||
}
|
||||
#ge-brush-softness-row label {
|
||||
flex: 0 0 auto;
|
||||
min-width: max-content;
|
||||
gap: 6px;
|
||||
}
|
||||
#ge-brush-softness-row #ge-brush-softness-label {
|
||||
position: static;
|
||||
min-width: 6ch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ge-controls--in-options #ge-brush-controls .ge-brush-size-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6ch, max-content) 96px auto;
|
||||
column-gap: 8px;
|
||||
}
|
||||
.ge-controls--in-options .ge-brush-size-row label,
|
||||
.ge-controls--in-options .ge-brush-size-row .ge-size-label {
|
||||
min-width: 6ch;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ge-controls--in-options .ge-control-row {
|
||||
margin: 0 !important;
|
||||
flex: 0 0 auto;
|
||||
@@ -4607,6 +4642,8 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ge-filter-modal {
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
width: 240px;
|
||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
@@ -4631,6 +4668,9 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
gap: 2px;
|
||||
}
|
||||
.ge-filter-row label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
@@ -9732,6 +9772,28 @@ body.ge-editor-open .tour-hint {
|
||||
margin-left: auto;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.ge-submenu .dropdown-item-compact {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
padding: 0 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ge-submenu .dropdown-item-compact svg {
|
||||
flex-shrink: 0;
|
||||
max-height: 16px;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.ge-submenu .dropdown-item-compact {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
}
|
||||
}
|
||||
.ge-submenu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
||||
@@ -1677,6 +1677,10 @@
|
||||
<label class="settings-label">Model</label>
|
||||
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
|
||||
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label">Quality</label>
|
||||
<select id="set-imgQualitySelect" class="settings-select">
|
||||
|
||||
+66
-1
@@ -1074,7 +1074,8 @@ async function loadEndpoints() {
|
||||
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
|
||||
</label>` : ''}
|
||||
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
|
||||
<span class="admin-user-name">${esc(epTitle)}</span>
|
||||
<span class="admin-user-name adm-ep-name" title="${esc(epTitle)}">${esc(epTitle)}</span>
|
||||
${_endpointSelectMode || (isChatgptAccount && ep.account_label) ? '' : `<button type="button" class="adm-ep-rename-btn" data-adm-rename-ep="${esc(ep.id)}" data-adm-ep-title="${esc(epTitle)}" title="Rename" aria-label="Rename endpoint"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>`}
|
||||
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
|
||||
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
|
||||
${statusBadge}
|
||||
@@ -1204,6 +1205,70 @@ async function loadEndpoints() {
|
||||
loadEndpoints();
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-rename-ep]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const nameEl = btn.previousElementSibling;
|
||||
if (!nameEl || !nameEl.classList.contains('admin-user-name')) return;
|
||||
const original = btn.dataset.admEpTitle || '';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = original;
|
||||
input.placeholder = 'Endpoint name';
|
||||
input.setAttribute('aria-label', 'Endpoint name');
|
||||
input.maxLength = 64;
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('data-bwignore', '');
|
||||
input.className = 'admin-user-name adm-ep-rename-input';
|
||||
input.style.minWidth = `min(${nameEl.getBoundingClientRect().width}px, 100%)`;
|
||||
nameEl.replaceWith(input);
|
||||
btn.style.visibility = 'hidden';
|
||||
input.focus();
|
||||
input.select();
|
||||
let done = false;
|
||||
const restore = () => {
|
||||
input.replaceWith(nameEl);
|
||||
btn.style.visibility = '';
|
||||
};
|
||||
const commit = async () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const name = input.value.trim();
|
||||
if (!name || name === original) { restore(); return; }
|
||||
const epId = btn.dataset.admRenameEp;
|
||||
if (data.some(ep => String(ep.id) !== epId && (ep.name || '').trim().toLowerCase() === name.toLowerCase())) {
|
||||
uiModule.showError(`An endpoint named "${name}" already exists`);
|
||||
done = false;
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
input.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${encodeURIComponent(epId)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to rename endpoint');
|
||||
}
|
||||
} catch (err) {
|
||||
uiModule.showError(err.message || 'Failed to rename endpoint');
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
await _refreshAfterEndpointChange();
|
||||
loadEndpoints();
|
||||
};
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done = true; restore(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-copy-url]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -771,7 +771,13 @@ function _getModal() {
|
||||
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">›</button>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
|
||||
_modal.querySelector('#cal-close').addEventListener('click', () => {
|
||||
if (_modal.querySelector('.cal-form')) {
|
||||
_render();
|
||||
return;
|
||||
}
|
||||
closeCalendar();
|
||||
});
|
||||
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
+159
-50
@@ -336,6 +336,9 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let _sendInFlight = false; // covers the window from click → streaming start
|
||||
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
||||
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
||||
// The user bubble a regenerate keeps on screen. Its resubmitted message is
|
||||
// a new DB row, so the saved id is attached here.
|
||||
let _regenKeptUserBubble = null;
|
||||
let _contextHeaderSeq = 0;
|
||||
let _contextHeaderData = null;
|
||||
let _contextHeaderBound = false;
|
||||
@@ -4995,6 +4998,15 @@ import { invalidateSettings } from './appConfig.js';
|
||||
refreshChatContextHeader('metrics');
|
||||
}
|
||||
|
||||
} else if (json.type === 'user_message_saved') {
|
||||
// The user bubble gets its DB id too, so regenerating or
|
||||
// editing it later cuts the chat by id, not page position.
|
||||
if (!_isBg && json.id) {
|
||||
if (_userMsgEl) _userMsgEl.dataset.dbId = json.id;
|
||||
else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id;
|
||||
_regenKeptUserBubble = null;
|
||||
}
|
||||
|
||||
} else if (json.type === 'message_saved') {
|
||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||
// can be edited/deleted immediately, without reloading the chat.
|
||||
@@ -7236,6 +7248,22 @@ import { invalidateSettings } from './appConfig.js';
|
||||
/**
|
||||
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
|
||||
*/
|
||||
// Recover attached file IDs from rendered user bubbles, including older
|
||||
// image previews that were rendered before data-file-id was stamped.
|
||||
function _userMessageAttachmentIds(msgElement) {
|
||||
if (!msgElement) return [];
|
||||
const els = msgElement.querySelectorAll('[data-file-id]');
|
||||
const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean);
|
||||
if (!ids.length) {
|
||||
const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const image of images) {
|
||||
const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function editUserMessage(userMsgElement) {
|
||||
const box = document.getElementById('chat-history');
|
||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||
@@ -7283,23 +7311,49 @@ import { invalidateSettings } from './appConfig.js';
|
||||
e.stopPropagation();
|
||||
const newText = editor.value.trim();
|
||||
if (!newText) return;
|
||||
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
|
||||
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
// Everything after the original reply to this message would also be
|
||||
// destroyed. Cancelling leaves the editor open with the draft intact.
|
||||
const extraCount = allMsgs.length - msgIndex - 2;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'edit');
|
||||
if (!choice) return;
|
||||
if (choice === 'fork') {
|
||||
// Branch just before this user turn and send the edit there; this
|
||||
// chat keeps the original message and everything after it.
|
||||
try {
|
||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds);
|
||||
bodyEl.innerHTML = originalHTML;
|
||||
} catch (err) {
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keepCount = msgIndex;
|
||||
// Cut at the message's database id: msgIndex counts only the messages
|
||||
// loaded in the page, so it is too small when older history has not
|
||||
// been scrolled in yet.
|
||||
const editBeforeId = userMsgElement.dataset.dbId || '';
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount })
|
||||
});
|
||||
if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status);
|
||||
|
||||
// Remove DOM elements from msgIndex onward
|
||||
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
||||
allMsgs[i].remove();
|
||||
}
|
||||
|
||||
// The edited user turn still refers to its original attachments.
|
||||
_pendingRegenAttachments = editAttachmentIds;
|
||||
// Submit the edited text
|
||||
const messageInput = uiModule.el('message');
|
||||
messageInput.value = newText;
|
||||
@@ -7323,6 +7377,59 @@ import { invalidateSettings } from './appConfig.js';
|
||||
});
|
||||
}
|
||||
|
||||
// Regenerate, resend and edit permanently delete (server-side truncate)
|
||||
// everything after the point clicked. Ask first unless nothing comes after
|
||||
// it (extraCount 0), offering a fork that leaves this chat untouched.
|
||||
// Resolves to 'proceed', 'fork', or null (cancelled).
|
||||
const _TRUNCATE_ACTIONS = {
|
||||
regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' },
|
||||
resend: { lead: 'Resending this message', confirmText: 'Delete and resend' },
|
||||
edit: { lead: 'Sending this edit', confirmText: 'Delete and send' },
|
||||
};
|
||||
async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') {
|
||||
if (extraCount <= 0) return 'proceed';
|
||||
const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate;
|
||||
const result = await uiModule.styledConfirm(
|
||||
`${lead} will also permanently delete the ${extraCount} ` +
|
||||
`message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` +
|
||||
`Fork from here keeps this chat as it is and continues in a new one instead.`,
|
||||
{ confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true }
|
||||
);
|
||||
if (result === 'alternate') return 'fork';
|
||||
return result ? 'proceed' : null;
|
||||
}
|
||||
|
||||
// Copy the first `keepCount` messages of the current chat into a new chat
|
||||
// and switch to it. Returns the new session id; throws on failure so callers
|
||||
// that replay an action in the fork can stop before sending anything.
|
||||
// Fork by message id when the element has one (before it, or through it);
|
||||
// keepCount is only a fallback, since it counts loaded messages only.
|
||||
async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') {
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) throw new Error('No active chat');
|
||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount,
|
||||
...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
await sessionModule.loadSessions();
|
||||
await sessionModule.selectSession(data.id);
|
||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
||||
return data.id;
|
||||
}
|
||||
|
||||
// Fork before a user message and send `text` there; this chat is untouched.
|
||||
// `attachments` (if given) are re-sent only once the fork has succeeded.
|
||||
async function _forkAndSend(keepCount, beforeMsgId, text, attachments) {
|
||||
await _forkSessionAt(keepCount, beforeMsgId);
|
||||
if (attachments !== undefined) _pendingRegenAttachments = attachments;
|
||||
uiModule.el('message').value = text;
|
||||
document.querySelector('.send-btn')?.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a user message. By default this replaces from the selected user turn
|
||||
* so failed attempts do not remain in model context or SFT traces. Callers can
|
||||
@@ -7344,18 +7451,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
||||
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
||||
|
||||
// Collect file_ids attached to this user message so the resend re-carries
|
||||
// the photos / docs (and the chat handler picks up the user-edited OCR
|
||||
// text cached server-side under those file ids).
|
||||
const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
|
||||
let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||
if (!_ids.length) {
|
||||
const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const _im of _imgs) {
|
||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
|
||||
}
|
||||
}
|
||||
const _ids = _userMessageAttachmentIds(userMsgElement);
|
||||
|
||||
// Rescue: legacy bubbles may have stored the filename as the message
|
||||
// content (artifact of earlier broken resends). Don't re-send that as
|
||||
@@ -7378,6 +7474,18 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|
||||
try {
|
||||
if (replaceFromHere) {
|
||||
// Everything strictly after the immediate AI reply to this user
|
||||
// message would also be destroyed — that's the part a click on
|
||||
// Resend / "Regenerate message" doesn't expect to lose.
|
||||
const extraCount = allMsgs.length - msgIndex - 2;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'resend');
|
||||
if (!choice) return;
|
||||
if (choice === 'fork') {
|
||||
// Branch just before this user turn and send it again there; this
|
||||
// chat keeps every message.
|
||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids);
|
||||
return;
|
||||
}
|
||||
// Resend/regenerate trims history to this point before resubmitting so
|
||||
// the replacement request is the only copy the backend sees.
|
||||
const keepCount = msgIndex;
|
||||
@@ -7442,25 +7550,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect any file_ids attached to the original user message so the
|
||||
// regenerated send re-uses them. Without this the AI is regenerated on
|
||||
// text alone — photos (and the user-edited OCR text cached server-side
|
||||
// under that file_id) would be silently dropped.
|
||||
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
|
||||
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||
// Fallback for bubbles rendered before the data-file-id stamp landed:
|
||||
// sniff the file id straight out of any `.attach-image-preview img`
|
||||
// src URLs (matches /api/upload/<id>). Otherwise an older bubble would
|
||||
// regen with zero attachments and the photo would be lost from the
|
||||
// resulting message even though the file still exists on disk.
|
||||
if (!_regenIds.length && userMsgEl) {
|
||||
const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const _im of _imgs) {
|
||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
|
||||
}
|
||||
}
|
||||
_pendingRegenAttachments = _regenIds;
|
||||
// Everything strictly after the AI reply being regenerated would also
|
||||
// be destroyed — that's the part a click on "Regenerate from here"
|
||||
// doesn't expect to lose.
|
||||
const extraCount = allMsgs.length - aiIndex - 1;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
|
||||
if (!choice) return;
|
||||
|
||||
const _regenIds = _userMessageAttachmentIds(userMsgEl);
|
||||
|
||||
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
||||
// photo's filename as the user-message content. On a follow-up regen,
|
||||
@@ -7469,7 +7566,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// an image file". If userText is just a bare image filename and we have
|
||||
// attachments, drop it so the OCR text (or the image bytes for vision
|
||||
// models) is what the model actually sees.
|
||||
if (userText && _pendingRegenAttachments.length &&
|
||||
if (userText && _regenIds.length &&
|
||||
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
|
||||
userText = '';
|
||||
}
|
||||
@@ -7477,7 +7574,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// A photo-only message has empty user text — regen must still proceed,
|
||||
// because the attachments themselves are the message. Bail only if there
|
||||
// is no text AND no attachments to send.
|
||||
if (!userText && !_pendingRegenAttachments.length) {
|
||||
if (!userText && !_regenIds.length) {
|
||||
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
|
||||
return;
|
||||
}
|
||||
@@ -7485,6 +7582,20 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
if (choice === 'fork') {
|
||||
// Branch just before the user turn and send it again there, so the new
|
||||
// chat gets a fresh reply to the same message; this chat keeps every
|
||||
// message. No variants or hidden bubble: the fork starts without them.
|
||||
try {
|
||||
await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds);
|
||||
} catch (err) {
|
||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current response as a variant
|
||||
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
||||
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
||||
@@ -7496,13 +7607,19 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
|
||||
const keepCount = userIndex;
|
||||
// Same as resend: cut at the user message's database id, because
|
||||
// userIndex counts only the messages loaded in the page.
|
||||
const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || '';
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount })
|
||||
});
|
||||
// A refused cut (e.g. a stale id) must not stack a second reply.
|
||||
if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status);
|
||||
_pendingRegenAttachments = _regenIds;
|
||||
|
||||
// Keep the original user bubble, but remove every rendered trace after
|
||||
// it, including agent-thread tool history between the user and AI bubble.
|
||||
@@ -7519,12 +7636,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_pendingVariantLabel = 'regen';
|
||||
|
||||
_hideUserBubble = true;
|
||||
_regenKeptUserBubble = userMsgEl;
|
||||
const messageInput = uiModule.el('message');
|
||||
messageInput.value = userText;
|
||||
const submitBtn = document.querySelector('.send-btn');
|
||||
if (submitBtn) submitBtn.click();
|
||||
|
||||
} catch (err) {
|
||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||
console.error('Regenerate failed:', err);
|
||||
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
|
||||
}
|
||||
@@ -7536,6 +7655,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// File-ids carried over from the original user message during a regen, so
|
||||
// photos / OCR overrides survive into the new send. Consumed once.
|
||||
let _pendingRegenAttachments = null;
|
||||
// Read-only view for tests.
|
||||
export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; }
|
||||
|
||||
/**
|
||||
* Called after streaming completes to attach variant navigation if this was a regen.
|
||||
@@ -7675,20 +7796,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
const keepCount = aiIndex + 1;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
|
||||
await sessionModule.loadSessions();
|
||||
await sessionModule.selectSession(data.id);
|
||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
||||
await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
|
||||
} catch (err) {
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function streamError(payload) {
|
||||
const detail = payload?.data ?? payload;
|
||||
const candidates = [detail?.message, detail?.text, detail?.error?.message,
|
||||
detail?.error, payload?.message, payload?.text, payload?.error?.message,
|
||||
payload?.error, typeof payload === 'string' ? payload : null];
|
||||
const message = candidates.find(value => typeof value === 'string' && value.trim());
|
||||
return new Error(message || 'Model request failed.');
|
||||
}
|
||||
|
||||
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
|
||||
export async function consumeCompareEvents(response, onEvent, onActivity) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let line = '', event = 'message', data = [], skipLF = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (!done) onActivity?.();
|
||||
let chunk = decoder.decode(value, { stream: !done });
|
||||
if (skipLF && chunk.length) {
|
||||
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
|
||||
skipLF = false;
|
||||
}
|
||||
const separators = /\r\n|\r|\n/g;
|
||||
let start = 0, separator;
|
||||
while ((separator = separators.exec(chunk))) {
|
||||
line += chunk.slice(start, separator.index);
|
||||
start = separator.index + separator[0].length;
|
||||
skipLF = separator[0] === '\r' && start === chunk.length;
|
||||
if (!line) {
|
||||
if (data.length || event === 'error') {
|
||||
const raw = data.join('\n');
|
||||
if (event !== 'error' && raw === '[DONE]') return;
|
||||
let payload;
|
||||
try { payload = JSON.parse(raw); }
|
||||
catch (_) {
|
||||
if (event === 'error') throw streamError(raw);
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('Invalid model stream data.');
|
||||
}
|
||||
await onEvent(payload);
|
||||
}
|
||||
event = 'message';
|
||||
data = [];
|
||||
} else if (!line.startsWith(':')) {
|
||||
const colon = line.indexOf(':');
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
let value = colon < 0 ? '' : line.slice(colon + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
if (field === 'data') data.push(value);
|
||||
else if (field === 'event') event = value || 'message';
|
||||
}
|
||||
line = '';
|
||||
}
|
||||
line += chunk.slice(start);
|
||||
if (done) throw new Error('Stream ended before completion.');
|
||||
}
|
||||
} finally {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
+12
-22
@@ -1,5 +1,6 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { consumeCompareEvents } from './sse.js';
|
||||
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
|
||||
import markdownModule from '../markdown.js';
|
||||
@@ -472,25 +473,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
_resetIdleTimeout(); // any chunk = stream is alive
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
await consumeCompareEvents(response,
|
||||
(json) => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.type === 'metrics') {
|
||||
metrics = json.data;
|
||||
|
||||
@@ -672,7 +657,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
} else if (json.delta) {
|
||||
// Skip text deltas if we already rendered an image
|
||||
if (aiMsgEl._imageData) continue;
|
||||
if (aiMsgEl._imageData) return;
|
||||
// Capture TTFT on very first text delta
|
||||
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
|
||||
// On first delta, destroy spinner and prepare text area
|
||||
@@ -701,8 +686,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
_scheduleLiveRender(target);
|
||||
}
|
||||
} catch (e) { console.warn('Compare stream render error:', e); }
|
||||
}
|
||||
}
|
||||
}, _resetIdleTimeout);
|
||||
|
||||
streamOk = true;
|
||||
// Destroy any remaining spinner
|
||||
@@ -900,9 +884,15 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
} else {
|
||||
console.error('Compare stream error:', error);
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
|
||||
awaitingChoice = false;
|
||||
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
|
||||
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
|
||||
}
|
||||
} finally {
|
||||
if (aiMsgEl._spinner) {
|
||||
aiMsgEl._spinner.destroy();
|
||||
aiMsgEl._spinner = null;
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
_timerDone = true;
|
||||
cancelAnimationFrame(_rafId);
|
||||
|
||||
@@ -1195,11 +1195,12 @@ async function _fetchDependencies() {
|
||||
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
||||
// append a caret button that toggles a per-row recipe panel below.
|
||||
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
||||
// Standalone recipe-caret button removed — the "Pick install
|
||||
// command" action lives inside the Installed ▾ dropdown menu
|
||||
// (see _showDepMenu) so each row only has ONE caret to click.
|
||||
// Kept the variable so downstream concat code stays the same.
|
||||
const recipeCaret = '';
|
||||
// The caret is the only way to open the recipe panel (the Installed ▾
|
||||
// menu has no recipe item), and the HW Fit / serve-card deep links
|
||||
// open it by clicking this caret (#6747).
|
||||
const recipeCaret = hasRecipe
|
||||
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
|
||||
: '';
|
||||
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
||||
// When llama_cpp (or any future engine) reports build_deps_missing
|
||||
// from its system_prereqs probe, surface a one-tap install button
|
||||
|
||||
@@ -5232,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleAttachUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = ''; // reset for next upload
|
||||
await _uploadComposeFiles(files);
|
||||
}
|
||||
@@ -5963,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
async function _handleMarkdownImageUpload(e) {
|
||||
const files = e.target.files;
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = '';
|
||||
await _uploadMarkdownImages(files);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { imageResultGeometry } from './image-result-geometry.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
@@ -184,17 +185,30 @@ export function wireInpaintButtons({
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
// OpenAI returns at one of its allowed sizes (1024²,
|
||||
// 1024×1536, 1536×1024) which often differs from our
|
||||
// canvas. Scale to canvas size with smoothing so the
|
||||
// inpaint blends in regardless of source dims.
|
||||
// Whole-image edits keep the provider's native frame; only masked
|
||||
// edits need registration with the source document's coordinates.
|
||||
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
||||
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
|
||||
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
const fullFrame = data.full_frame === true;
|
||||
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
|
||||
const resultLayer = createLayer(layerName,
|
||||
fullFrame ? geometry.width : state.imgWidth,
|
||||
fullFrame ? geometry.height : state.imgHeight);
|
||||
resultLayer.ctx.imageSmoothingEnabled = true;
|
||||
resultLayer.ctx.imageSmoothingQuality = 'high';
|
||||
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
|
||||
if (fullFrame) {
|
||||
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
|
||||
state.layerOffsets.set(resultLayer.id, {
|
||||
x: geometry.offsetX,
|
||||
y: geometry.offsetY,
|
||||
});
|
||||
} else {
|
||||
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
|
||||
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
|
||||
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
|
||||
}
|
||||
// Snapshot the AI result + hard mask used for this run.
|
||||
if (!fullFrame) {
|
||||
const aiSnap = document.createElement('canvas');
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
@@ -205,6 +219,7 @@ export function wireInpaintButtons({
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
}
|
||||
state.layers.push(resultLayer);
|
||||
state.activeLayerId = resultLayer.id;
|
||||
state.lastInpaintLayerId = resultLayer.id;
|
||||
@@ -220,6 +235,10 @@ export function wireInpaintButtons({
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (fullFrame) {
|
||||
if (uiModule) uiModule.showToast('Image edit complete', 4500);
|
||||
return;
|
||||
}
|
||||
// Reveal post-generation Feather + Edge Stroke sliders.
|
||||
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
||||
// more AI buffer than we generated.
|
||||
|
||||
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
|
||||
label: ((opt && opt.textContent) || '').trim(),
|
||||
};
|
||||
}
|
||||
const inp = document.getElementById('ge-ai-inpaint');
|
||||
const opt = resolveInpaintModel(inp);
|
||||
if (!opt) return { endpoint: '', model: '', label: '' };
|
||||
const raw = opt.value || '';
|
||||
const idx = raw.indexOf('::');
|
||||
return {
|
||||
endpoint: idx < 0 ? raw : raw.slice(0, idx),
|
||||
model: idx < 0 ? '' : raw.slice(idx + 2),
|
||||
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
|
||||
};
|
||||
const generation = document.getElementById('ge-ai-model');
|
||||
if (generation?.value && generation.value !== '__serve_cookbook__') {
|
||||
const index = generation.value.indexOf('::');
|
||||
const model = index < 0 ? '' : generation.value.slice(index + 2);
|
||||
if (model && modelCaps(model, '', '').inpaint) {
|
||||
return { endpoint: generation.value.slice(0, index), model,
|
||||
label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
|
||||
}
|
||||
}
|
||||
return { endpoint: '', model: '', label: 'Auto (saved settings)' };
|
||||
}
|
||||
|
||||
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
|
||||
@@ -144,21 +144,19 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
||||
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 => {
|
||||
if (typeof window !== 'undefined') {
|
||||
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 || '');
|
||||
});
|
||||
}
|
||||
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
|
||||
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
|
||||
});
|
||||
|
||||
@@ -256,9 +254,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
const data = 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="">Auto-detect</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</option>';
|
||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
@@ -346,45 +344,15 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
// Sentinel changes are handled by the component's delegated listener.
|
||||
if (aiCommandSelect) {
|
||||
aiCommandSelect._modelsReady = true;
|
||||
try {
|
||||
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
|
||||
let saved = rawSaved;
|
||||
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
||||
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
||||
const matching = loaded.filter(l => l.model === modelPart);
|
||||
if (matching.length === 1) {
|
||||
saved = matching[0].value;
|
||||
} else {
|
||||
saved = '';
|
||||
}
|
||||
try {
|
||||
if (saved) localStorage.setItem('ge-ai-command-model', saved);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
} catch {}
|
||||
}
|
||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
|
||||
aiCommandSelect.value = saved;
|
||||
} else {
|
||||
aiCommandSelect.value = '';
|
||||
}
|
||||
if (aiCommandSelect.value !== rawSaved) {
|
||||
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Legacy URL migration check: legacy endpoints with raw URLs are scrubbed.
|
||||
const scrubModelUrl = (saved) => (saved && saved.startsWith('http://') ? '' : saved);
|
||||
if (aiCommandSelect._commandWired !== listeners) {
|
||||
aiCommandSelect._commandWired = listeners;
|
||||
listen(aiCommandSelect, 'change', () => {
|
||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||
const val = aiCommandSelect.value || '';
|
||||
try {
|
||||
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
} catch {}
|
||||
const val = scrubModelUrl(aiCommandSelect.value) || '';
|
||||
updateFooterEditModel();
|
||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||
globalThis.window?.dispatchEvent?.(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -409,6 +377,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
});
|
||||
}
|
||||
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
||||
container.dispatchEvent(new Event('ge-image-models-ready'));
|
||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||
} catch (e) {
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
||||
* tuner that runs on the most-recent rembg cutout.
|
||||
*
|
||||
* rembg-run button: flatten + POST to /api/image/remove-bg with an
|
||||
* rembg-run button: active layer + POST to /api/image/remove-bg with an
|
||||
* optional hint_mask if the user has a wand/lasso selection
|
||||
* active. After the new layer lands, hides every previously-
|
||||
* visible layer so the cutout reads cleanly, and binds the
|
||||
* live-tuner to the new layer.
|
||||
* active. Updates only that layer and binds its live edge tuner;
|
||||
* other layers and their visibility remain unchanged.
|
||||
*
|
||||
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
||||
* moment it lands; subsequent feather/grow slider tweaks
|
||||
@@ -65,37 +64,13 @@ export function wireRembgAndSharpen({
|
||||
// NB: edge_feather / edge_grow are applied CLIENT-side so the
|
||||
// sliders can re-tune the cutout without re-running the model.
|
||||
const btn = document.getElementById('ge-rembg-run');
|
||||
const before = state.layers.length;
|
||||
// Snapshot which layers were visible BEFORE the run so we know
|
||||
// which to hide after a successful cutout.
|
||||
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
|
||||
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
// applyImageTool resolves after the returned image is decoded and the
|
||||
// new layer is inserted. Keep a short poll for defensive compatibility
|
||||
// with alternate runners that may still commit on the next frame.
|
||||
let frames = 0;
|
||||
while (state.layers.length <= before && frames < 60) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
frames++;
|
||||
}
|
||||
if (state.layers.length > before) {
|
||||
const newLayer = state.layers[state.layers.length - 1];
|
||||
bindRembgLiveTuner(newLayer);
|
||||
// Auto-hide underlying layers so the user sees just the
|
||||
// cutout — the eye toggles back on if they re-enable manually.
|
||||
for (const layer of state.layers) {
|
||||
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
|
||||
layer.visible = false;
|
||||
}
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
}
|
||||
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
// Reset sliders so the new cutout starts clean.
|
||||
const f = document.getElementById('ge-rembg-feather');
|
||||
const g = document.getElementById('ge-rembg-grow');
|
||||
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
|
||||
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
|
||||
if (editedLayer) bindRembgLiveTuner(editedLayer);
|
||||
});
|
||||
|
||||
// ── Live edge-cleanup tuner ──
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
|
||||
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
|
||||
*/
|
||||
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
|
||||
import { updateFooterEditModel } from './ai-models.js';
|
||||
|
||||
export function createAiSettings({ container }) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ge-ai-settings';
|
||||
@@ -19,11 +22,14 @@ export function createAiSettings({ container }) {
|
||||
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="ge-ai-settings-body">
|
||||
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
|
||||
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
|
||||
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
|
||||
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
|
||||
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
|
||||
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
|
||||
<div class="ge-ai-settings-strength">
|
||||
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
|
||||
@@ -41,6 +47,7 @@ export function createAiSettings({ container }) {
|
||||
|
||||
function open(focusId) {
|
||||
el.hidden = false;
|
||||
syncModels();
|
||||
if (focusId) {
|
||||
const target = document.getElementById(focusId);
|
||||
if (target) {
|
||||
@@ -56,10 +63,59 @@ export function createAiSettings({ container }) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || el.hidden) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
|
||||
container.appendChild(el);
|
||||
const status = el.querySelector('#ge-ai-settings-save-status');
|
||||
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
|
||||
function applyModels(settings) {
|
||||
for (const [id, key] of modelFields) {
|
||||
if (!(key in settings)) continue;
|
||||
const select = el.querySelector(`#${id}`);
|
||||
const model = settings[key] || '';
|
||||
let option = [...select.options].find(option => {
|
||||
const value = option.value;
|
||||
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
|
||||
});
|
||||
if (!option && model && select.options.length) {
|
||||
option = document.createElement('option');
|
||||
option.value = `::${model}`;
|
||||
option.textContent = `${model} (unavailable)`;
|
||||
option.disabled = true;
|
||||
select.append(option);
|
||||
}
|
||||
if (option) select.value = option.value;
|
||||
}
|
||||
updateFooterEditModel();
|
||||
}
|
||||
async function syncModels() {
|
||||
try { applyModels(await readImagePreferences()); }
|
||||
catch (error) { status.textContent = error.message; }
|
||||
}
|
||||
for (const [id, key] of modelFields) {
|
||||
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === '__serve_cookbook__') return;
|
||||
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
|
||||
try {
|
||||
status.textContent = 'Saving…';
|
||||
await saveImagePreferences({ [key]: model });
|
||||
status.textContent = 'Saved';
|
||||
} catch (error) {
|
||||
await syncModels();
|
||||
status.textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
const onPreferencesChanged = event => applyModels(event.detail);
|
||||
window.addEventListener('image-preferences-changed', onPreferencesChanged);
|
||||
container.addEventListener('ge-image-models-ready', syncModels);
|
||||
|
||||
const strengthInput = el.querySelector('#ge-look-strength');
|
||||
const strengthVal = el.querySelector('#ge-look-strength-val');
|
||||
@@ -77,7 +133,9 @@ export function createAiSettings({ container }) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
|
||||
container.removeEventListener('ge-image-models-ready', syncModels);
|
||||
el.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,12 @@ export function createApplyImageTool({
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
const flatCanvas = flatten();
|
||||
const removeBackground = endpoint === '/api/image/remove-bg';
|
||||
const targetLayer = removeBackground ? state.layers.find(layer => layer.id === state.activeLayerId) : null;
|
||||
if (removeBackground && (!targetLayer?.canvas || targetLayer.kind === 'adjustment' || targetLayer.locks?.pixels)) {
|
||||
throw new Error('Select an unlocked image layer first');
|
||||
}
|
||||
const flatCanvas = removeBackground ? targetLayer.canvas : flatten();
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const body = { image: imageB64, ...extraPayload };
|
||||
const res = await fetch(endpoint, {
|
||||
@@ -96,6 +101,23 @@ export function createApplyImageTool({
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
if (removeBackground) {
|
||||
if (!state.layers.includes(targetLayer)) return;
|
||||
saveState('Remove background');
|
||||
// The returned pixels replace the editable source, not just its preview.
|
||||
targetLayer.kind = 'raster';
|
||||
targetLayer.placed = null;
|
||||
targetLayer.text = null;
|
||||
targetLayer.shape = null;
|
||||
targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||
targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||
targetLayer._adjCacheKey = null;
|
||||
targetLayer._adjFinalKey = null;
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule?.showToast('Background removed');
|
||||
return targetLayer;
|
||||
}
|
||||
{
|
||||
saveState();
|
||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
|
||||
Vendored
+5
-5
@@ -55,6 +55,11 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Color</label>
|
||||
<input type="color" class="ge-color-picker" value="${color}" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-softness" min="0" max="1200" value="100" title="Brush edge softness and feathered falloff" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-size-row">
|
||||
<label><span class="ge-size-label">${brushSize}px</span></label>
|
||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||
@@ -94,11 +99,6 @@ export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance
|
||||
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-brush-softness-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
|
||||
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
|
||||
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-brush-stroke-group" data-stroke-tool="eraser">
|
||||
<div class="ge-section-title">Eraser</div>
|
||||
|
||||
@@ -78,6 +78,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const UI_CHROME_SELECTOR = '.ge-topbar, .ge-toolbar, .ge-controls, .ge-right-panel, '
|
||||
+ '.ge-dock, .ge-options-bar, .ge-canvas-toolbar, button, input, select, textarea';
|
||||
const isUiChrome = e => !!e.target?.closest?.(UI_CHROME_SELECTOR);
|
||||
const canStartOutside = () => ['marquee', 'lasso', 'crop'].includes(state.tool);
|
||||
// Only in-progress selection/crop drags are affected by the chrome
|
||||
// guard. A committed cropRect/selection is a resting state — clicking
|
||||
// its Apply button must not be treated as "strayed onto chrome".
|
||||
@@ -143,15 +144,16 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
state.mainCanvas.addEventListener('pointerup', endPen);
|
||||
state.mainCanvas.addEventListener('pointercancel', endPen);
|
||||
// Lasso and Crop can start OUTSIDE the canvas — fallback mousedown on the
|
||||
// Selection and Crop can start OUTSIDE the canvas — fallback mousedown on the
|
||||
// surrounding canvas-area so the user can begin a path/rect in the empty
|
||||
// space around the image. Other tools stay canvas-only.
|
||||
canvasArea.addEventListener('mousedown', (e) => {
|
||||
if (state.tool !== 'lasso' && state.tool !== 'crop') return;
|
||||
if (!canStartOutside() || Date.now() < suppressMouseUntil) return;
|
||||
if (e.button !== 0 || isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
|
||||
if (e.target === state.mainCanvas) return; // already handled
|
||||
// Ignore presses on editor chrome inside the area (the crop Apply panel,
|
||||
// popups, buttons) so clicking Apply does not start a fresh crop.
|
||||
if (e.target.closest?.('button, input, select, textarea, .ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
|
||||
if (isUiChrome(e) || e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
|
||||
beginDraw(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
@@ -305,6 +307,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
let panning = false;
|
||||
let pid = null;
|
||||
let transformPointerId = null;
|
||||
let outsideDrawPointerId = null;
|
||||
let startX = 0, startY = 0;
|
||||
let startPanX = 0, startPanY = 0;
|
||||
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
|
||||
@@ -315,10 +318,23 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
||||
// With the crop (or lasso) tool, an empty-area press draws a rect/path
|
||||
// With a selection or crop tool, an empty-area press draws a rect/path
|
||||
// rather than panning — so the crop can be pulled out past the image.
|
||||
// Space-held / middle-mouse (directPan) still pans.
|
||||
if ((state.tool === 'lasso' || state.tool === 'crop') && !directPan) return;
|
||||
if (canStartOutside() && !directPan) {
|
||||
if (e.target === state.mainCanvas || isUiChrome(e) ||
|
||||
e.target.closest?.('.ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-fx-menu')) return;
|
||||
// Mouse starts through mousedown; pen/touch need their own captured path.
|
||||
if (e.pointerType === 'pen' || e.pointerType === 'touch') {
|
||||
if (outsideDrawPointerId !== null || e.button !== 0) return;
|
||||
outsideDrawPointerId = e.pointerId;
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
try { canvasArea.setPointerCapture(e.pointerId); } catch {}
|
||||
beginDraw(e);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
|
||||
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
|
||||
if (onImage && !directPan) return;
|
||||
@@ -357,6 +373,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
e.preventDefault();
|
||||
});
|
||||
canvasArea.addEventListener('pointermove', (e) => {
|
||||
if (outsideDrawPointerId !== null && e.pointerId === outsideDrawPointerId) {
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
if (!isUiChrome(e)) continueDraw(e);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (transformPointerId !== null && e.pointerId === transformPointerId) {
|
||||
continueDraw(e);
|
||||
return;
|
||||
@@ -370,6 +392,14 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
applyOffset(next.x, next.y);
|
||||
});
|
||||
const endPan = (e) => {
|
||||
if (outsideDrawPointerId !== null && (!e || e.pointerId === outsideDrawPointerId)) {
|
||||
const capturedId = outsideDrawPointerId;
|
||||
outsideDrawPointerId = null;
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
if (e?.type === 'pointercancel') cancelDraw?.();
|
||||
else if (e) endDraw(e);
|
||||
try { canvasArea.releasePointerCapture(capturedId); } catch {}
|
||||
}
|
||||
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
|
||||
const capturedId = transformPointerId;
|
||||
transformPointerId = null;
|
||||
|
||||
@@ -21,6 +21,7 @@ export const EDITOR_AGENT_VERSION = '1.0';
|
||||
export const DEFAULT_AGENT_ENDPOINT = '/api/editor/agent';
|
||||
|
||||
import { formatCropRect, isGenericIntent } from './nl-edit.js';
|
||||
import { waitForImagePreferences } from '../settings/imagePreferences.js';
|
||||
|
||||
export function createEditorAgent({
|
||||
control,
|
||||
@@ -28,6 +29,7 @@ export function createEditorAgent({
|
||||
fetchImpl = null,
|
||||
getSession = null,
|
||||
getModel = null,
|
||||
getGenerationModel = null,
|
||||
getAgentModel = null,
|
||||
onStep = null,
|
||||
} = {}) {
|
||||
@@ -145,6 +147,7 @@ export function createEditorAgent({
|
||||
activeStep = report;
|
||||
executed = [];
|
||||
running = true;
|
||||
await waitForImagePreferences();
|
||||
|
||||
// Honest before-image (captured before any client pre-step) for the
|
||||
// imperceptible-edit check, then the history mark scoping the undo group.
|
||||
@@ -194,6 +197,8 @@ export function createEditorAgent({
|
||||
signal: signal || undefined,
|
||||
body: JSON.stringify({
|
||||
prompt, session, editor_session: session, state, screenshot,
|
||||
_use_image_settings: true,
|
||||
generation_model: getGenerationModel ? getGenerationModel() : '',
|
||||
model: (modelInfo && modelInfo.model) || '',
|
||||
endpoint: (modelInfo && modelInfo.endpoint) || '',
|
||||
model_label: (modelInfo && modelInfo.label) || '',
|
||||
|
||||
@@ -132,7 +132,7 @@ function renderEffects(source, effects, masks) {
|
||||
const contribution = maskedContribution(next, masks[index]);
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
|
||||
Vendored
+2
-1
@@ -81,6 +81,7 @@ export function normalizeEffect(effect) {
|
||||
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
||||
}
|
||||
if (type === 'drop-shadow') {
|
||||
params.inside = params.inside === true || params.inside === 1;
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
||||
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
||||
@@ -291,7 +292,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
||||
: next;
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
if (effect.type === 'drop-shadow' && !effect.params.inside) blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { decodeAIImage } from './ai-operation.js';
|
||||
import { waitForImagePreferences } from '../settings/imagePreferences.js';
|
||||
|
||||
export function createImageGenerationAction({
|
||||
getDocument, placeResult, getSignal = () => null, onProgress = () => {},
|
||||
formatError = info => info.detail || 'Image generation failed',
|
||||
fetchImpl = (...args) => fetch(...args), timeoutMs = 330000,
|
||||
}) {
|
||||
return async function generateImage(params = {}) {
|
||||
const document = getDocument();
|
||||
if (!document.key) throw new Error('Open a document before generating an image');
|
||||
const controller = new AbortController();
|
||||
const parentSignal = getSignal();
|
||||
const cancel = () => controller.abort();
|
||||
if (parentSignal?.aborted) cancel();
|
||||
parentSignal?.addEventListener('abort', cancel, { once: true });
|
||||
let timedOut = false;
|
||||
const deadline = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
||||
const started = Date.now();
|
||||
const progress = setInterval(() => onProgress(Math.floor((Date.now() - started) / 1000)), 1000);
|
||||
// A selection can change while the provider works; preserve this request's mask.
|
||||
let mask = null;
|
||||
try {
|
||||
if (params.masked && document.mask) {
|
||||
mask = globalThis.document.createElement('canvas');
|
||||
mask.width = document.mask.width; mask.height = document.mask.height;
|
||||
mask.getContext('2d').drawImage(document.mask, 0, 0);
|
||||
}
|
||||
await waitForImagePreferences();
|
||||
controller.signal.throwIfAborted();
|
||||
let response;
|
||||
try { response = await fetchImpl(window.geGenerateImageEndpoint || '/api/image/generate', {
|
||||
method: 'POST', credentials: 'same-origin', signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ _use_image_settings: true, prompt: params.prompt || '',
|
||||
width: Math.max(1, Math.round(Number(params.width) || document.width)),
|
||||
height: Math.max(1, Math.round(Number(params.height) || document.height)) }),
|
||||
}); } catch (error) {
|
||||
if (controller.signal.aborted) throw error;
|
||||
throw new Error(formatError({ network: true, detail: error.message }));
|
||||
}
|
||||
let data;
|
||||
try { data = await response.json(); }
|
||||
catch { throw new Error(response.ok ? 'Image server returned an invalid response' : `Image server failed (${response.status})`); }
|
||||
if (!response.ok || data.error) throw new Error(formatError({ status: response.status,
|
||||
detail: data.detail || data.error || `Image server failed (${response.status})` }));
|
||||
const image = data.image || data.images?.[0];
|
||||
if (!image) throw new Error('Image generation returned no image');
|
||||
const decoded = await decodeAIImage(image, controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
const current = getDocument();
|
||||
if (current.key !== document.key || current.width !== document.width || current.height !== document.height) {
|
||||
throw new Error('The document changed during generation. The result was not placed in another document.');
|
||||
}
|
||||
const canvas = globalThis.document.createElement('canvas');
|
||||
canvas.width = decoded.naturalWidth; canvas.height = decoded.naturalHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(decoded, 0, 0);
|
||||
return placeResult(canvas, 'Generated', mask);
|
||||
} catch (error) {
|
||||
if (timedOut) throw new Error('Image generation timed out. Try again or choose a faster image model.');
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
clearInterval(progress);
|
||||
parentSignal?.removeEventListener('abort', cancel);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function imageResultGeometry(width, height, canvasWidth, canvasHeight) {
|
||||
const matchingRatio = Math.abs(width * canvasHeight - height * canvasWidth) <= Math.max(canvasWidth, canvasHeight);
|
||||
if (matchingRatio) {
|
||||
const scale = Math.max(canvasWidth / width, canvasHeight / height);
|
||||
const drawWidth = width * scale, drawHeight = height * scale;
|
||||
return { width: canvasWidth, height: canvasHeight, offsetX: 0, offsetY: 0,
|
||||
drawX: (canvasWidth - drawWidth) / 2, drawY: (canvasHeight - drawHeight) / 2, drawWidth, drawHeight };
|
||||
}
|
||||
return { width, height, offsetX: Math.round((canvasWidth - width) / 2), offsetY: Math.round((canvasHeight - height) / 2),
|
||||
drawX: 0, drawY: 0, drawWidth: width, drawHeight: height };
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function wireKeyboardShortcuts(deps) {
|
||||
toolbar, toolKeyMap,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync, openLayerAdjustment,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
||||
lassoDeleteSelection, lassoCopyToLayer,
|
||||
@@ -181,6 +181,12 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||
if (e.shiftKey && !e.altKey && e.code === 'KeyU') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openLayerAdjustment?.('hue-saturation');
|
||||
return;
|
||||
}
|
||||
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||
@@ -221,11 +227,12 @@ export function wireKeyboardShortcuts(deps) {
|
||||
addEmptyLayer();
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+L — Levels adjustment. The Image menu owns the row and its
|
||||
// dialog, so click it instead of duplicating the adjustment here.
|
||||
// Ctrl+Alt+L edits the current layer, leaving Levels controls open.
|
||||
if (e.altKey && e.code === 'KeyL') {
|
||||
const row = document.querySelector('[data-image-action="adj-levels"]');
|
||||
if (row) { e.preventDefault(); e.stopPropagation(); row.click(); return; }
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openLayerAdjustment?.('levels');
|
||||
return;
|
||||
}
|
||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||
// existing implementation, which preserves masks and effects.
|
||||
|
||||
@@ -2233,6 +2233,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
if (dragSortModule) {
|
||||
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
|
||||
instanceKey: 'ge-layers',
|
||||
excludeSelector: '.ge-adj-sub-item',
|
||||
threshold: 4,
|
||||
ignoreSelector: 'button, input, select, textarea, a, [contenteditable="true"], .ge-adj-sub-item',
|
||||
onReorder: (orderedItems) => {
|
||||
@@ -2258,6 +2259,9 @@ export function createLayerPanelRenderer(deps) {
|
||||
state.layers = newLayers;
|
||||
normalizeLayerClipping(state);
|
||||
composite();
|
||||
// Effects, adjustments and masks are sibling rows, not children
|
||||
// of the dragged row. Rebuild them under their reordered owner.
|
||||
render();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const size = () => number('size', 'Size', 8, 0, 100);
|
||||
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
|
||||
export const LAYER_STYLES = {
|
||||
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
|
||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] },
|
||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 300)] },
|
||||
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
|
||||
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
|
||||
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
|
||||
@@ -32,6 +32,13 @@ function surface(width, height) {
|
||||
}
|
||||
|
||||
export function renderDropShadow(source, params) {
|
||||
if (params.inside) {
|
||||
return renderLayerStyle(source, 'inner-shadow', {
|
||||
color: params.color, opacity: params.opacity * 100, size: params.blur / 2,
|
||||
distance: Math.hypot(params.x, params.y),
|
||||
angle: Math.atan2(-params.y, params.x) * 180 / Math.PI,
|
||||
});
|
||||
}
|
||||
// Tint alpha first so RGB content cannot leak into the shadow. Draw only
|
||||
// this silhouette; compositing the source again thickens translucent edges.
|
||||
const silhouette = surface(source.width, source.height);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function finishQuickEditJob(job, result) {
|
||||
if (job.cancelled || result?.cancelled) {
|
||||
job.cancelled = true;
|
||||
job.status = 'cancelled';
|
||||
job.detail = 'Cancelled';
|
||||
} else if (job.status === 'error' || result?.ok === false) {
|
||||
job.status = 'error';
|
||||
job.detail = result?.error || result?.message || (job.detail !== 'Working…' && job.detail) || 'Edit failed';
|
||||
} else {
|
||||
job.status = 'done';
|
||||
job.detail = result?.message || 'Done';
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,26 @@ function toolSettings(tool) {
|
||||
|
||||
let _lastColor = null;
|
||||
let _lastTransparentColor = null;
|
||||
function transparentColor(color) {
|
||||
if (color === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||
_lastColor = color;
|
||||
function transparentColor(color, alpha = 0) {
|
||||
const key = `${color}|${alpha}`;
|
||||
if (key === _lastColor && _lastTransparentColor) return _lastTransparentColor;
|
||||
_lastColor = key;
|
||||
_lastTransparentColor = colorWithAlpha(color, alpha);
|
||||
return _lastTransparentColor;
|
||||
}
|
||||
|
||||
function colorWithAlpha(color, alpha) {
|
||||
if (color.startsWith('#')) {
|
||||
const raw = color.slice(1);
|
||||
const expanded = raw.length === 3 ? raw.split('').map(char => char + char).join('') : raw;
|
||||
const rgb = [0, 2, 4].map(index => parseInt(expanded.slice(index, index + 2), 16));
|
||||
_lastTransparentColor = `rgba(${rgb.join(',')},0)`;
|
||||
return _lastTransparentColor;
|
||||
return `rgba(${rgb.join(',')},${alpha})`;
|
||||
}
|
||||
const match = color.match(/rgba?\(([^)]+)\)/);
|
||||
_lastTransparentColor = match ? `rgba(${match[1].split(',').slice(0, 3).join(',')},0)` : 'rgba(0,0,0,0)';
|
||||
return _lastTransparentColor;
|
||||
if (!match) return `rgba(0,0,0,${alpha})`;
|
||||
const components = match[1].split(',');
|
||||
const originalAlpha = components.length > 3 ? Number(components[3]) : 1;
|
||||
return `rgba(${components.slice(0, 3).join(',')},${alpha * originalAlpha})`;
|
||||
}
|
||||
|
||||
function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||
@@ -38,8 +45,19 @@ function circleStamp(ctx, x, y, radius, softness, color, alpha, operation) {
|
||||
ctx.fillStyle = color;
|
||||
} else {
|
||||
const gradient = ctx.createRadialGradient(x, y, radius * hard, x, y, radius);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, transparentColor(color));
|
||||
if (softness > 1) {
|
||||
// Above the old maximum, remove the firm core with a progressively
|
||||
// gentler alpha profile while keeping the selected brush diameter.
|
||||
ctx.fillStyle = color;
|
||||
const normalized = ctx.fillStyle;
|
||||
for (let step = 0; step <= 16; step++) {
|
||||
const t = step / 16;
|
||||
gradient.addColorStop(t, transparentColor(normalized, (1 - t) ** softness));
|
||||
}
|
||||
} else {
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, transparentColor(color));
|
||||
}
|
||||
ctx.fillStyle = gradient;
|
||||
}
|
||||
ctx.beginPath();
|
||||
@@ -274,6 +292,10 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
||||
if (!PAINT_TOOLS.has(tool)) return;
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
// Pixel writes invalidate adjusted previews even when adjustment settings
|
||||
// themselves have not changed (otherwise retouch strokes appear late).
|
||||
layer._adjFinalKey = null;
|
||||
layer._adjCacheKey = null;
|
||||
if (tool === 'clone') return cloneStamp(sample, layer);
|
||||
if (tool === 'heal') {
|
||||
// A healing stroke without a picked source uses the convenient local
|
||||
@@ -294,7 +316,7 @@ export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composit
|
||||
let operation = 'source-over';
|
||||
let color = state.color;
|
||||
let alpha = opacity * flow;
|
||||
let softness = Math.max(0, Math.min(1, settings.softness / 300));
|
||||
let softness = Math.max(0, Math.min(4, settings.softness / 300));
|
||||
if (tool === 'eraser') { operation = 'destination-out'; color = 'rgba(0,0,0,1)'; }
|
||||
else if (tool === 'inpaint') {
|
||||
operation = state.inpaintEraseStroke ? 'destination-out' : 'source-over';
|
||||
|
||||
@@ -57,8 +57,11 @@ function wireToolSliders(prefix, fields) {
|
||||
// softness rises (the CSS already sets the radial gradient — we
|
||||
// just tween the inner solid radius to communicate the falloff).
|
||||
if (softPrev) {
|
||||
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
|
||||
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||
const amount = state[fields.softness];
|
||||
const innerStop = Math.max(0, 60 - amount * 0.55);
|
||||
softPrev.style.background = amount > 300
|
||||
? `radial-gradient(circle, var(--fg), color-mix(in srgb, var(--fg) ${Math.round(100 * 0.5 ** (amount / 300))}%, transparent) 50%, transparent 100%)`
|
||||
: `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,23 @@ export function wireSubmenuToggles(menu) {
|
||||
menu.classList.remove('ge-flyout-open');
|
||||
}
|
||||
});
|
||||
function positionPanel(parent) {
|
||||
const panel = parent.nextElementSibling;
|
||||
if (!panel?.classList.contains('ge-submenu')) return;
|
||||
const row = parent.getBoundingClientRect();
|
||||
const origin = (panel.offsetParent || menu).getBoundingClientRect();
|
||||
const top = Math.max(8, Math.min(row.top, window.innerHeight - panel.offsetHeight - 8));
|
||||
const left = row.right + panel.offsetWidth > window.innerWidth - 8
|
||||
? Math.max(8, row.left - panel.offsetWidth) : row.right - 2;
|
||||
panel.style.top = `${top - origin.top + (panel.offsetParent?.scrollTop || 0)}px`;
|
||||
panel.style.left = `${left - origin.left + (panel.offsetParent?.scrollLeft || 0)}px`;
|
||||
}
|
||||
for (const eventName of ['pointerover', 'focusin']) {
|
||||
menu.addEventListener(eventName, (event) => {
|
||||
const parent = event.target.closest('.ge-menu-parent');
|
||||
if (parent) positionPanel(parent);
|
||||
});
|
||||
}
|
||||
menu.addEventListener('click', (e) => {
|
||||
const parent = e.target.closest('.ge-menu-parent');
|
||||
if (!parent) return;
|
||||
@@ -34,10 +51,7 @@ export function wireSubmenuToggles(menu) {
|
||||
parent.setAttribute('aria-expanded', String(willOpen));
|
||||
if (willOpen) {
|
||||
// Align the flyout with the clicked row rather than the menu top.
|
||||
const panel = parent.nextElementSibling;
|
||||
if (panel && panel.classList.contains('ge-submenu')) {
|
||||
panel.style.top = parent.offsetTop + 'px';
|
||||
}
|
||||
positionPanel(parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { topPortalZ } from '../toolWindowZOrder.js';
|
||||
import { isLayerPixelLocked, isLayerTransparencyLocked } from './layer-groups.js';
|
||||
|
||||
const TOPBAR_MENU_IDS = ['ge-file-menu', 'ge-edit-menu', 'ge-view-menu', 'ge-image-menu', 'ge-layer-menu', 'ge-type-menu', 'ge-selection-menu', 'ge-filter-menu', 'ge-window-menu', 'ge-help-menu', 'ge-resize-menu', 'ge-save-menu'];
|
||||
@@ -101,6 +102,7 @@ export function wireTopbar(deps) {
|
||||
};
|
||||
const positionSaveMenu = () => {
|
||||
const r = saveBtn.getBoundingClientRect();
|
||||
saveMenu.style.zIndex = String(topPortalZ());
|
||||
saveMenu.style.top = `${r.bottom + 2}px`;
|
||||
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
|
||||
saveMenu.style.left = 'auto';
|
||||
|
||||
@@ -2650,7 +2650,14 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
|
||||
// Wire events
|
||||
document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
|
||||
document.getElementById('email-lib-close').addEventListener('click', () => {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (modal?.classList.contains('email-reading') || modal?.querySelector('.doclib-card-expanded')) {
|
||||
_exitEmailReaderModeForList();
|
||||
return;
|
||||
}
|
||||
closeEmailLibrary();
|
||||
});
|
||||
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
|
||||
|
||||
// Clicking the modal header (anywhere except buttons/inputs) collapses
|
||||
|
||||
@@ -73,16 +73,24 @@ function _isDangerousSrcset(value) {
|
||||
|
||||
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
|
||||
export function _escLinkify(text) {
|
||||
const escaped = _esc(text);
|
||||
// URLs: http(s)://... or www....
|
||||
const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
|
||||
const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
||||
return escaped
|
||||
.replace(urlRe, (m) => {
|
||||
const href = m.startsWith('www.') ? `https://${m}` : m;
|
||||
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
})
|
||||
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
|
||||
const raw = String(text || '');
|
||||
// Match raw text once so generated anchors are never linkified again.
|
||||
const tokenRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])|\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
|
||||
const parts = [];
|
||||
let offset = 0;
|
||||
for (const match of raw.matchAll(tokenRe)) {
|
||||
parts.push(_esc(raw.slice(offset, match.index)));
|
||||
const token = match[0];
|
||||
const isUrl = !!match[1];
|
||||
const href = isUrl
|
||||
? (token.startsWith('www.') ? `https://${token}` : token)
|
||||
: `mailto:${token}`;
|
||||
const attrs = isUrl ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
parts.push(`<a href="${_attrEsc(_esc(href))}"${attrs}>${_esc(token)}</a>`);
|
||||
offset = match.index + token.length;
|
||||
}
|
||||
parts.push(_esc(raw.slice(offset)));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
// Pull display name out of "Name <email@x>"; fallback to local-part of
|
||||
|
||||
@@ -1321,7 +1321,12 @@ function _renderEditorLanding() {
|
||||
// openEditor() now returns a Promise — it's async because the size
|
||||
// prompt is a styled modal. Await it before checking whether the
|
||||
// editor actually opened (the user may have cancelled).
|
||||
await openEditor(null, null, null, 'New canvas');
|
||||
try {
|
||||
await openEditor(null, null, null, 'New canvas');
|
||||
} catch (error) {
|
||||
// openEditor reports the initialization error and tears down its shell.
|
||||
console.error('[gallery] New canvas failed', error);
|
||||
}
|
||||
if (!document.querySelector('#gallery-editor-container .gallery-editor')) {
|
||||
_renderEditorLanding();
|
||||
}
|
||||
|
||||
+151
-59
@@ -277,8 +277,11 @@ import { alphaBounds as _maskAlphaBounds, featherMaskCanvas as _featherMaskCanva
|
||||
import { analyzeAutoTone as _analyzeAutoTone, applyAutoTone as _applyAutoTone, boxBlurRGB as _boxBlurRGB, skinMaskFromData as _skinMaskFromData, frequencySeparationSmooth as _frequencySeparationSmooth, blemishRemoval as _blemishRemoval, teethWhitening as _teethWhitening, featureExclusionMask as _featureExclusionMask, dilateAlpha as _dilateAlpha, subtractMask as _subtractMask, guidedClarity as _guidedClarity } from './editor/retouch.js';
|
||||
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
|
||||
import { wireAIModelSelectors, resolveInpaintModel, resolveQuickEditModel, resolveQuickEditAgentModel } from './editor/ai-models.js';
|
||||
import { createPreflight } from './editor/preflight.js';
|
||||
import { createAiSettings } from './editor/ai-settings.js';
|
||||
import { waitForImagePreferences } from './settings/imagePreferences.js';
|
||||
import { createImageGenerationAction } from './editor/image-generation.js';
|
||||
import { finishQuickEditJob } from './editor/quick-edit-job.js';
|
||||
import { imageResultGeometry } from './editor/image-result-geometry.js';
|
||||
import { lookRecipeSteps } from './editor/look-recipes.js';
|
||||
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
||||
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
||||
@@ -356,7 +359,6 @@ function _computeTransformSnap(frame, center) {
|
||||
const _INPAINT_DEFAULT_BRUSH = 100;
|
||||
let _samAbortController = null;
|
||||
let _precisionGuides = null;
|
||||
let _preflight = null;
|
||||
let _aiSettings = null;
|
||||
let _selectionAnimationFrame = null;
|
||||
let _selectionAnimationLast = 0;
|
||||
@@ -394,6 +396,12 @@ if (!window.__galleryEditEscHardGuardInstalled) {
|
||||
}
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
if (e.key === 'Escape') {
|
||||
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
|
||||
if (aiSettings) {
|
||||
e.preventDefault(); e.stopImmediatePropagation();
|
||||
aiSettings.querySelector('[data-ai-settings-close]')?.click();
|
||||
return;
|
||||
}
|
||||
// Floating dialogs/popups that live outside the editor modal must close on
|
||||
// Escape reliably (their document-capture handlers can miss when focus is
|
||||
// outside them). Close the top-most one and stop here.
|
||||
@@ -838,6 +846,7 @@ function _setCanvasAiBusy(busy) {
|
||||
}
|
||||
|
||||
let _aiProgressTimer = null;
|
||||
let _aiProgressEpoch = 0;
|
||||
function _setAiProgress(frac, label) {
|
||||
const wrap = document.getElementById('ge-ai-command-progress');
|
||||
if (!wrap) return;
|
||||
@@ -857,12 +866,17 @@ function _startAiProgress(requestId) {
|
||||
const endpointId = arguments[1] || '';
|
||||
_stopAiProgress();
|
||||
if (!requestId) return;
|
||||
const epoch = _aiProgressEpoch;
|
||||
let pending = false;
|
||||
const tick = async () => {
|
||||
if (pending || epoch !== _aiProgressEpoch) return;
|
||||
pending = true;
|
||||
try {
|
||||
const epQuery = endpointId ? `&endpoint_id=${encodeURIComponent(endpointId)}` : '';
|
||||
const r = await fetch(`/api/image/edit-progress?request_id=${encodeURIComponent(requestId)}` + epQuery, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const p = await r.json();
|
||||
if (epoch !== _aiProgressEpoch) return;
|
||||
const st = p.stage || '';
|
||||
if (st === 'done' || st === 'error') { _setAiProgress(1, st === 'done' ? 'Done' : 'Failed'); return; }
|
||||
const frac = typeof p.progress === 'number' ? p.progress : 0;
|
||||
@@ -871,12 +885,13 @@ function _startAiProgress(requestId) {
|
||||
const verb = st === 'editing' ? 'Generating' : (st ? st[0].toUpperCase() + st.slice(1) : 'Working');
|
||||
_setAiProgress(frac, `${verb}…${stepTxt}${eta}`);
|
||||
_setAiCommandStatus(`${verb}…${stepTxt}${eta}`, 'running');
|
||||
} catch (_) {}
|
||||
} catch (_) {} finally { pending = false; }
|
||||
};
|
||||
_aiProgressTimer = setInterval(tick, 1000);
|
||||
tick();
|
||||
}
|
||||
function _stopAiProgress() {
|
||||
_aiProgressEpoch++;
|
||||
if (_aiProgressTimer) { clearInterval(_aiProgressTimer); _aiProgressTimer = null; }
|
||||
_setAiProgress(null);
|
||||
}
|
||||
@@ -1074,6 +1089,7 @@ const _QUICK_EDIT_TOOL_SUGGESTIONS = Object.entries(_TOOLS).map(([id, t]) => ({
|
||||
kind: 'Tool',
|
||||
icon: t.icon || '',
|
||||
toolId: id,
|
||||
runPrompt: id === 'rembg' ? 'remove background' : null,
|
||||
aliases: [id],
|
||||
}));
|
||||
const _QUICK_EDIT_CATALOG = [..._QUICK_EDIT_TOOL_SUGGESTIONS, ..._AI_COMMAND_SUGGESTIONS];
|
||||
@@ -1281,8 +1297,13 @@ function _wireAiCommandBox() {
|
||||
if (item.toolId) {
|
||||
_clickToolButton(item.toolId);
|
||||
_setAiCommandStatus(`${item.label} tool`, 'done');
|
||||
if (item.runPrompt) {
|
||||
input.value = item.runPrompt;
|
||||
syncClearButton();
|
||||
}
|
||||
hideSuggestions();
|
||||
input.focus();
|
||||
if (run && item.runPrompt) form.requestSubmit();
|
||||
return true;
|
||||
}
|
||||
input.value = item.insert;
|
||||
@@ -1471,6 +1492,24 @@ function _wireAiCommandBox() {
|
||||
// model (window.geEditorAI) just like the server would route it.
|
||||
const _executeQuickEditLocal = async (prompt) => {
|
||||
const p = prompt.toLowerCase();
|
||||
// Background-removal intent (shared with the routing guard so the regex
|
||||
// can never drift between the two call sites).
|
||||
if (_looksBackgroundRemoval(p)) {
|
||||
await _runQuickControlOp('ai.removeBackground', {});
|
||||
// Chain a tone step when the prompt also asks for one
|
||||
// ("remove the background and make it brighter", "...and warm it up").
|
||||
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
|
||||
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
|
||||
} else if (/\b(warm(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
|
||||
} else if (/\b(cool(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
|
||||
}
|
||||
_setAiCommandStatus('Background removal finished.', 'done');
|
||||
return;
|
||||
}
|
||||
// A targeted recolour ("make only the red car blue", "recolour the rock to
|
||||
// teal") needs the model to repaint a specific object — never fake it with a
|
||||
// global colour tweak from the deterministic fast path.
|
||||
@@ -1933,24 +1972,6 @@ function _wireAiCommandBox() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Background-removal intent (shared with the routing guard so the regex
|
||||
// can never drift between the two call sites).
|
||||
if (_looksBackgroundRemoval(p)) {
|
||||
await _runQuickControlOp('ai.removeBackground', {});
|
||||
// Chain a tone step when the prompt also asks for one
|
||||
// ("remove the background and make it brighter", "...and warm it up").
|
||||
if (/\b(bright(?:er|en)?|lighten|exposure|well[-\s]*lit)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: 16, contrast: 4 } });
|
||||
} else if (/\b(dark(?:er|en)?|dimmer)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'brightness-contrast', params: { brightness: -14, contrast: 4 } });
|
||||
} else if (/\b(warm(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: 18 } });
|
||||
} else if (/\b(cool(?:er)?)\b/.test(p)) {
|
||||
await _runQuickControlOp('adjustments.apply', { type: 'white-balance', params: { temperature: -18 } });
|
||||
}
|
||||
_setAiCommandStatus('Background removal finished.', 'done');
|
||||
return;
|
||||
}
|
||||
if (!/\b(text|title|caption|font|heading|subtitle)\b/.test(p)
|
||||
&& /\b(upscale|higher\s*res|increase\s*resolution|bigger|2x|4x)\b/.test(p)) {
|
||||
// Use the real upscale op (Real-ESRGAN, basic-upscale fallback). The
|
||||
@@ -2814,8 +2835,8 @@ function _wireAiCommandBox() {
|
||||
if (_looksBackgroundRemoval(prompt)) {
|
||||
_setAiCommandStatus('Removing the background…', 'running');
|
||||
try {
|
||||
const done = await _executeQuickEditLocal(prompt);
|
||||
if (done !== false) return { ok: true };
|
||||
await _executeQuickEditLocal(prompt);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) { _setAiCommandStatus('', ''); return { ok: false, cancelled: true }; }
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
@@ -2841,6 +2862,7 @@ function _wireAiCommandBox() {
|
||||
_setAiCommandStatus('Generated an image.', 'done');
|
||||
return { ok: true, image: res };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { ok: false, cancelled: true };
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
_setAiCommandStatus(_isGenOfflineMsg(msg) ? msg : `Could not generate an image: ${msg}`, 'error');
|
||||
return { ok: false, error: msg };
|
||||
@@ -3083,19 +3105,22 @@ function _wireAiCommandBox() {
|
||||
job.controller = controller;
|
||||
renderJobStack();
|
||||
_quickEditActiveJob = job;
|
||||
let result;
|
||||
try {
|
||||
await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
|
||||
result = await executeQuickEdit(job.prompt, { forceAi: !!job.forceAi, signal: controller ? controller.signal : null });
|
||||
} catch (err) {
|
||||
if (!job.cancelled) {
|
||||
job.status = 'error';
|
||||
job.detail = err?.message || 'AI edit failed';
|
||||
}
|
||||
} finally {
|
||||
_stopAiProgress();
|
||||
finishQuickEditJob(job, result);
|
||||
_setAiCommandStatus(job.cancelled ? '' : job.detail, job.cancelled ? '' : job.status);
|
||||
if (_quickEditActiveJob === job) _quickEditActiveJob = null;
|
||||
job.onChange = null;
|
||||
job.controller = null;
|
||||
if (job.cancelled) continue;
|
||||
if (job.status === 'running') job.status = 'done';
|
||||
renderJobStack();
|
||||
setTimeout(() => dismissJob(job), job.status === 'error' ? 3200 : 1600);
|
||||
}
|
||||
@@ -5304,9 +5329,14 @@ function _restoreState(snap) {
|
||||
// inside _buildEditor, so we touch the DOM here).
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight} px`;
|
||||
state.rembgLiveLayer = null;
|
||||
state.rembgLiveSnap = null;
|
||||
}
|
||||
|
||||
function undo() {
|
||||
// End sampling before restoring pixels; pointer-up must not replay the
|
||||
// unfinished stroke into the restored layer without a new history entry.
|
||||
if (state.drawing) _strokeTool.tryEnd();
|
||||
if (state.undoStack.length === 0) return;
|
||||
const target = state.undoStack.pop();
|
||||
const cur = _snapshotState();
|
||||
@@ -5759,7 +5789,20 @@ function _updateBrushCursor(e) {
|
||||
state.cursorEl.style.height = diameterY + 'px';
|
||||
}
|
||||
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
|
||||
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
// Updating an inline style on every move invalidates document-wide :has()
|
||||
// selectors. A paused animation keeps the same transform in the rendering
|
||||
// pipeline without mutating the cursor's style attribute on every frame.
|
||||
const cursorTransform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
if (!state.cursorEl._positionAnimation || state.cursorEl._positionAnimation.playState === 'idle') {
|
||||
state.cursorEl._positionAnimation = state.cursorEl.animate(
|
||||
[{ transform: cursorTransform }, { transform: cursorTransform }],
|
||||
{ duration: 1, fill: 'both' },
|
||||
);
|
||||
state.cursorEl._positionAnimation.pause();
|
||||
state.cursorEl._positionAnimation.currentTime = 0;
|
||||
} else {
|
||||
state.cursorEl._positionAnimation.effect.setKeyframes([{ transform: cursorTransform }, { transform: cursorTransform }]);
|
||||
}
|
||||
if (state.cursorEl.style.display !== 'block') {
|
||||
state.cursorEl.style.display = 'block';
|
||||
}
|
||||
@@ -6522,6 +6565,11 @@ const _nudgeTransform = _transformSession.nudgeTransform;
|
||||
const _confirmTransform = _transformSession.confirmTransform;
|
||||
const _cancelTransform = _transformSession.cancelTransform;
|
||||
window.__galleryEditorHandleEscape = () => {
|
||||
const aiSettings = document.querySelector('.ge-ai-settings:not([hidden])');
|
||||
if (aiSettings) {
|
||||
aiSettings.querySelector('[data-ai-settings-close]')?.click();
|
||||
return true;
|
||||
}
|
||||
const sizePrompt = document.getElementById('ge-canvas-size-overlay');
|
||||
if (sizePrompt && getComputedStyle(sizePrompt).display !== 'none') {
|
||||
sizePrompt._cancelCanvasSize?.();
|
||||
@@ -8051,6 +8099,7 @@ async function _canMutateLayerPixels(layer, action = 'editing pixels') {
|
||||
return false;
|
||||
}
|
||||
if (!['placed', 'text', 'shape'].includes(layer.kind)) return true;
|
||||
if (action === 'cutting pixels') return _rasterizeLayerNow(layer);
|
||||
return _confirmRasterizeLayer(layer);
|
||||
}
|
||||
|
||||
@@ -8249,10 +8298,13 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
// standard slim red-thumb styling instead of the bare browser
|
||||
// default. Value chip on the same line as the label.
|
||||
const isColor = p.type === 'color';
|
||||
const isSelect = p.type === 'select';
|
||||
rows += `
|
||||
<div class="ge-filter-row ge-eraser-row">
|
||||
<label>${p.label}${isColor ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
||||
${isColor
|
||||
<label>${p.label}${isColor || isSelect ? '' : ` <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span>`}</label>
|
||||
${isSelect
|
||||
? `<select data-key="${p.key}" class="ge-ctl-select" aria-label="${p.label}">${p.options.map(option => `<option value="${option.value}"${String(option.value) === String(p.value) ? ' selected' : ''}>${option.label}</option>`).join('')}</select>`
|
||||
: isColor
|
||||
? `<input type="color" data-key="${p.key}" value="${p.value}" aria-label="${p.label}" />`
|
||||
: `<input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />`}
|
||||
</div>
|
||||
@@ -8273,7 +8325,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
for (const p of params) values[p.key] = p.value;
|
||||
// Initial preview render.
|
||||
try { onPreview(values); } catch {}
|
||||
overlay.querySelectorAll('input[data-key]').forEach(inp => {
|
||||
overlay.querySelectorAll('input[data-key], select[data-key]').forEach(inp => {
|
||||
inp.addEventListener('input', (e) => {
|
||||
if (!isCurrent()) { cleanup(null); return; }
|
||||
const k = e.target.dataset.key;
|
||||
@@ -8304,7 +8356,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
e.preventDefault();
|
||||
cleanup(e.target?.dataset.action === 'cancel' ? null : values);
|
||||
} else if (e.key === 'Tab') {
|
||||
const fields = [...overlay.querySelectorAll('input, button')];
|
||||
const fields = [...overlay.querySelectorAll('input, select, button')];
|
||||
const index = fields.indexOf(document.activeElement);
|
||||
e.preventDefault();
|
||||
fields[(index + (e.shiftKey ? -1 : 1) + fields.length) % fields.length]?.focus();
|
||||
@@ -8316,7 +8368,7 @@ function _filterSliderPrompt(title, params, onPreview, onCancel) {
|
||||
if (!overlay.isConnected || !isCurrent()) cleanup(null);
|
||||
});
|
||||
observer.observe(state.container, { childList: true, subtree: true });
|
||||
overlay.querySelector('input, button')?.focus({ preventScroll: true });
|
||||
overlay.querySelector('input, select, button')?.focus({ preventScroll: true });
|
||||
overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
|
||||
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
|
||||
// Click outside the modal (on the dim backdrop) = cancel.
|
||||
@@ -8482,6 +8534,7 @@ function _shadowOffsets(values = {}) {
|
||||
return {
|
||||
x: Math.round(Math.cos(radians) * distance),
|
||||
y: Math.round(-Math.sin(radians) * distance),
|
||||
inside: Number(values.inside) === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8543,6 +8596,7 @@ async function _addRetainedEffect(type, presetName = null) {
|
||||
{ key: 'color', label: 'Color', type: 'color', value: '#000000' },
|
||||
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: 45, suffix: '%' },
|
||||
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: 12, suffix: 'px' },
|
||||
{ key: 'inside', label: 'Position', type: 'select', value: 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
|
||||
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).distance, suffix: 'px' },
|
||||
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance({ x: 4, y: 6 }).angle, suffix: '\u00b0' },
|
||||
];
|
||||
@@ -8756,6 +8810,7 @@ async function _editRetainedEffect(layer, effect) {
|
||||
{ key: 'color', label: 'Color', type: 'color', value: p.color || '#000000' },
|
||||
{ key: 'opacity', label: 'Opacity', min: 0, max: 100, step: 1, value: Math.round((Number(p.opacity) || 0) * 100), suffix: '%' },
|
||||
{ key: 'blur', label: 'Blur', min: 0, max: 100, step: 1, value: Number(p.blur) || 0, suffix: 'px' },
|
||||
{ key: 'inside', label: 'Position', type: 'select', value: p.inside ? 1 : 0, options: [{ value: 0, label: 'Outside' }, { value: 1, label: 'Inside' }] },
|
||||
{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance(p).distance, suffix: 'px' },
|
||||
{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance(p).angle, suffix: '\u00b0' },
|
||||
];
|
||||
@@ -9980,9 +10035,8 @@ function _buildEditor(container) {
|
||||
if (colorRow) colorRow.style.display = ['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify'].includes(toolId) ? 'none' : '';
|
||||
const colorLabel = colorRow?.querySelector('label');
|
||||
if (colorLabel) colorLabel.textContent = 'Color';
|
||||
// Per-tool stroke modifiers all live in the shared brush "More" menu so
|
||||
// every brush-family tool has the same compact strip as the Brush. Only
|
||||
// the group for the active tool is shown; the rest stay hidden.
|
||||
// Secondary modifiers stay in More; brush softness remains in the main
|
||||
// strip. Only the active tool's secondary group is shown.
|
||||
const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'
|
||||
: toolId === 'eraser' ? 'eraser'
|
||||
: (toolId === 'clone' || toolId === 'heal') ? 'clone'
|
||||
@@ -9992,6 +10046,8 @@ function _buildEditor(container) {
|
||||
row.style.display = row.dataset.strokeTool === strokeTool ? '' : 'none';
|
||||
});
|
||||
const dodgeBurnRow = document.getElementById('ge-dodgeburn-intensity-row');
|
||||
const brushSoftnessRow = document.getElementById('ge-brush-softness-row');
|
||||
if (brushSoftnessRow) brushSoftnessRow.style.display = ['brush', 'dodge', 'burn'].includes(toolId) ? '' : 'none';
|
||||
// Intensity sits in the strip next to Size, so it must show for Dodge and
|
||||
// Burn only (and never for the other brush-family tools).
|
||||
if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';
|
||||
@@ -10444,7 +10500,6 @@ function _buildEditor(container) {
|
||||
canvasArea.appendChild(state.transformOverlay);
|
||||
canvasArea.appendChild(_buildQuickMaskBar());
|
||||
canvasArea.appendChild(_buildAiCommandBox());
|
||||
_preflight = createPreflight({ canvasArea });
|
||||
_aiSettings = createAiSettings({ container });
|
||||
try { window.geEditorAiSettings = _aiSettings; } catch (_) {}
|
||||
// On phones the rulers eat scarce canvas width; default them off.
|
||||
@@ -10637,19 +10692,10 @@ function _buildEditor(container) {
|
||||
const initialStatusTool = document.getElementById('ge-status-tool');
|
||||
if (initialStatusTool) initialStatusTool.textContent = _TOOLS[state.tool]?.label || state.tool || '';
|
||||
}
|
||||
// Preflight checklist: greet the user on open (unless dismissed) and
|
||||
// expose a footer button to re-open it.
|
||||
// Keep model configuration in the AI settings dialog.
|
||||
try {
|
||||
const footer = container.querySelector('.ge-editor-footer');
|
||||
if (footer) {
|
||||
const setupBtn = document.createElement('button');
|
||||
setupBtn.type = 'button';
|
||||
setupBtn.id = 'ge-setup-btn';
|
||||
setupBtn.className = 'ge-setup-btn';
|
||||
setupBtn.title = 'Editor setup checklist';
|
||||
setupBtn.textContent = 'Setup';
|
||||
setupBtn.addEventListener('click', () => _preflight?.open());
|
||||
footer.appendChild(setupBtn);
|
||||
const aiBtn = document.createElement('button');
|
||||
aiBtn.type = 'button';
|
||||
aiBtn.id = 'ge-ai-settings-btn';
|
||||
@@ -10659,7 +10705,6 @@ function _buildEditor(container) {
|
||||
aiBtn.addEventListener('click', () => _aiSettings?.open());
|
||||
footer.appendChild(aiBtn);
|
||||
}
|
||||
_preflight?.maybeOpen();
|
||||
} catch (_) {}
|
||||
try { _initControlKit(container); } catch (_) {}
|
||||
let _lastLayoutDesktop = null;
|
||||
@@ -11779,6 +11824,14 @@ function _buildEditor(container) {
|
||||
// Keyboard shortcuts — full implementation in
|
||||
// editor/keyboard-shortcuts.js.
|
||||
wireKeyboardShortcuts({
|
||||
openLayerAdjustment: (type) => {
|
||||
const layer = activeLayer();
|
||||
if (!layer || !layer.canvas || layer.kind === 'adjustment') {
|
||||
uiModule?.showToast('Select an image layer first');
|
||||
return;
|
||||
}
|
||||
_openAdjPopup(layer, type, document.getElementById('ge-image-menu-btn'));
|
||||
},
|
||||
fillSelection: _doFillSelection,
|
||||
copyPixelsToClipboard: _copyPixelsToClipboard,
|
||||
toolbar, toolKeyMap: _toolKeyMap,
|
||||
@@ -12143,7 +12196,9 @@ function _buildEditor(container) {
|
||||
out.getContext('2d').drawImage(layer.canvas, off.x, off.y);
|
||||
return out;
|
||||
};
|
||||
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = false } = {}) => {
|
||||
const _aiEditCanvas = async (endpoint, payload = {}, { keepNaturalSize = true } = {}) => {
|
||||
await waitForImagePreferences();
|
||||
if (endpoint === '/api/image/inpaint') payload = { ...payload, _use_image_settings: true };
|
||||
const flat = _aiEditSourceCanvas();
|
||||
const image = flat.toDataURL('image/png').split(',')[1];
|
||||
const requestId = 'qe-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
@@ -12154,7 +12209,7 @@ function _buildEditor(container) {
|
||||
} else {
|
||||
_startAiProgress(requestId);
|
||||
}
|
||||
const body = JSON.stringify({ image, request_id: requestId, ...payload });
|
||||
const body = JSON.stringify({ image, width: flat.width, height: flat.height, request_id: requestId, ...payload });
|
||||
// The image servers occasionally reject a request transiently (503 while a
|
||||
// model swaps, 502/504 gateways, dropped connections). Retry those a couple
|
||||
// of times with backoff so a hiccup doesn't silently leave the canvas
|
||||
@@ -12212,7 +12267,7 @@ function _buildEditor(container) {
|
||||
c.height = state.imgHeight;
|
||||
}
|
||||
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
|
||||
return { canvas: c, dataUrl: c.toDataURL('image/png') };
|
||||
return { canvas: c, dataUrl: c.toDataURL('image/png'), fullFrame: data.full_frame === true };
|
||||
} finally {
|
||||
if (_timer) clearTimeout(_timer);
|
||||
if (_aiEditAbort === _ctl) _aiEditAbort = null;
|
||||
@@ -12221,12 +12276,17 @@ function _buildEditor(container) {
|
||||
};
|
||||
const _placeResultLayer = (canvas, name, maskCanvas) => {
|
||||
_saveState(`AI: ${name}`);
|
||||
const layer = createLayer(name, state.imgWidth, state.imgHeight);
|
||||
layer.ctx.drawImage(canvas, 0, 0);
|
||||
const geometry = imageResultGeometry(canvas.width, canvas.height, state.imgWidth, state.imgHeight);
|
||||
const layer = createLayer(name, geometry.width, geometry.height);
|
||||
const offset = { x: geometry.offsetX, y: geometry.offsetY };
|
||||
state.layerOffsets.set(layer.id, offset);
|
||||
layer.ctx.imageSmoothingEnabled = true;
|
||||
layer.ctx.imageSmoothingQuality = 'high';
|
||||
layer.ctx.drawImage(canvas, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
|
||||
if (maskCanvas) {
|
||||
layer.ctx.save();
|
||||
layer.ctx.globalCompositeOperation = 'destination-in';
|
||||
layer.ctx.drawImage(maskCanvas, 0, 0);
|
||||
layer.ctx.drawImage(maskCanvas, -offset.x, -offset.y);
|
||||
layer.ctx.restore();
|
||||
}
|
||||
state.layers.push(layer);
|
||||
@@ -12236,13 +12296,24 @@ function _buildEditor(container) {
|
||||
_renderLayerPanel();
|
||||
return layer.id;
|
||||
};
|
||||
const _generateImage = createImageGenerationAction({
|
||||
formatError: _generationErrorMessage,
|
||||
getDocument: () => ({
|
||||
key: state.editorOpen ? `${state.editorSessionToken}:${state.documentId}` : null,
|
||||
width: state.imgWidth, height: state.imgHeight, mask: state.wandMask,
|
||||
}),
|
||||
getSignal: () => _quickEditActiveJob?.controller?.signal,
|
||||
onProgress: seconds => _setAiCommandStatus(`Generating an image… ${seconds}s`, 'running'),
|
||||
placeResult: _placeResultLayer,
|
||||
});
|
||||
const _aiInpaintSelection = async (prompt) => {
|
||||
if (!state.wandMask) throw new Error('make a selection first');
|
||||
const layer = activeLayer() || _activeParentLayer();
|
||||
if (!layer) throw new Error('no layer');
|
||||
const mask = state.wandMask.toDataURL('image/png').split(',')[1];
|
||||
const { canvas } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
||||
return _placeResultLayer(canvas, 'Generative fill', state.wandMask);
|
||||
const selection = state.wandMask;
|
||||
const { canvas, fullFrame } = await _aiEditCanvas('/api/image/inpaint', { mask, prompt: prompt || '', steps: 4 });
|
||||
return _placeResultLayer(canvas, 'Generative fill', fullFrame ? null : selection);
|
||||
};
|
||||
// Replace the background/scene while keeping the subject: the server derives
|
||||
// the subject mask, repaints the inverse (new background), harmonises colour
|
||||
@@ -12535,7 +12606,7 @@ function _buildEditor(container) {
|
||||
'layers.delete': { description: 'Delete layers (defaults to selection).', run: (p) => { if (p && (p.id || p.index || p.name)) _controlSelect(_controlFindLayer(p)); _layerPanelRenderer.deleteSelectedLayers?.(); return true; } },
|
||||
'layers.setOpacity': { description: 'Set a layer opacity (0-1).', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change opacity of ${l.name}`); l.opacity = Math.max(0, Math.min(1, Number(p.opacity))); composite(); _renderLayerPanel(); return l.id; } },
|
||||
'layers.setBlend': { description: 'Set a layer blend mode.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState(`Change blend mode of ${l.name}`); l.blendMode = p.blendMode || p.mode || 'source-over'; composite(); _renderLayerPanel(); return l.id; } },
|
||||
'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = (p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'; const beforeSnap = useDiff ? flatten() : null; const layer = createLayer(p.name || 'Placed image', dw, dh); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, 0, 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) },
|
||||
'layers.placeImage': { description: 'Place an image (data URL) as a NEW layer, masked to the current selection when one exists; never overwrites the original. Instruction mode (mode:"instruction" or differenceMask:true) keeps untouched areas pixel-identical via a difference map. {dataUrl,name,masked,blend,opacity,x,y,w,h,mode,differenceMask,diffThreshold,diffGrow,diffFeather}.', run: (p) => new Promise((resolve, reject) => { if (!p || !p.dataUrl) { reject(new Error('dataUrl required')); return; } const img = new Image(); img.onload = () => { try { _saveState(`Place ${p.name || 'image'}`); const dw = state.imgWidth, dh = state.imgHeight; const useDiff = p.nativeSize !== true && ((p.differenceMask === true) || String(p.mode || '').toLowerCase() === 'instruction'); const beforeSnap = useDiff ? flatten() : null; const nativeSize = p.nativeSize === true && !useDiff; const geometry = imageResultGeometry(img.naturalWidth, img.naturalHeight, dw, dh); const layer = createLayer(p.name || 'Placed image', nativeSize ? geometry.width : dw, nativeSize ? geometry.height : dh); const nativeOffset = { x: geometry.offsetX, y: geometry.offsetY }; if (nativeSize) state.layerOffsets.set(layer.id, nativeOffset); let tw = Number(p.w), th = Number(p.h); if (!(tw > 0) || !(th > 0)) { if (nativeSize) { tw = geometry.drawWidth; th = geometry.drawHeight; } else if (useDiff) { tw = dw; th = dh; } else { const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight); tw = Math.max(1, Math.round(img.naturalWidth * scale)); th = Math.max(1, Math.round(img.naturalHeight * scale)); } } let tx = Number(p.x), ty = Number(p.y); if (!Number.isFinite(tx)) tx = nativeSize ? geometry.drawX : useDiff ? 0 : Math.round((dw - tw) / 2); if (!Number.isFinite(ty)) ty = nativeSize ? geometry.drawY : useDiff ? 0 : Math.round((dh - th) / 2); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, tx, ty, tw, th); if (p.masked !== false && state.wandMask) { layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(state.wandMask, nativeSize ? -nativeOffset.x : 0, nativeSize ? -nativeOffset.y : 0); layer.ctx.restore(); } else if (useDiff && beforeSnap) { const diff = _differenceMaskCanvas(beforeSnap, layer.canvas, { threshold: p.diffThreshold, grow: p.diffGrow, feather: p.diffFeather, edgeExcludePx: 0 }); const explicitEdge = p.edgeExcludePx != null ? Number(p.edgeExcludePx) : null; const sceneReplace = diff.changedFraction > 0.5; const edgeExclude = sceneReplace ? 0 : (explicitEdge != null ? explicitEdge : 3); if (edgeExclude > 0) _clearMaskEdgeBand(diff.mask, edgeExclude); layer.ctx.save(); layer.ctx.globalCompositeOperation = 'destination-in'; layer.ctx.drawImage(diff.mask, 0, 0); layer.ctx.restore(); if (sceneReplace) _edgeFillFromInterior(layer.ctx, dw, dh, 3); } if (p.blend) layer.blendMode = p.blend; if (p.opacity != null) layer.opacity = Math.max(0, Math.min(1, Number(p.opacity))); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; _renderLayerPanel(); composite(); resolve(layer.id); } catch (e) { reject(e); } }; img.onerror = () => reject(new Error('image load failed')); img.src = p.dataUrl; }) },
|
||||
'layers.addMaskFromSelection': { description: 'Add a layer mask from the current selection (reveals the selection). {invert}.', run: (p) => { const parent = _activeParentLayer(); if (!parent) throw new Error('no layer'); _saveState('Add layer mask'); const mask = _ensureActiveMaskLayer(); if (!mask) throw new Error('could not create mask'); const w = mask.canvas.width, h = mask.canvas.height; const mctx = mask.ctx; const off = state.layerOffsets.get(parent.id) || { x: 0, y: 0 }; const ox = -(off.x || 0), oy = -(off.y || 0); mctx.globalCompositeOperation = 'source-over'; mctx.clearRect(0, 0, w, h); if (state.wandMask) { if (p && p.invert) { mctx.fillStyle = '#fff'; mctx.fillRect(0, 0, w, h); mctx.globalCompositeOperation = 'destination-out'; mctx.drawImage(state.wandMask, ox, oy); mctx.globalCompositeOperation = 'source-over'; } else { mctx.drawImage(state.wandMask, ox, oy); } } else { mctx.fillStyle = (p && p.invert) ? '#000' : '#fff'; mctx.fillRect(0, 0, w, h); } composite(); _renderLayerPanel(); return mask.id; } },
|
||||
'layers.removeMask': { description: 'Remove the active layer mask (does not affect pixels).', run: () => { const parent = _activeParentLayer(); if (!parent || !parent.masks || !parent.masks.length) throw new Error('no mask'); _saveState('Remove layer mask'); parent.masks = parent.masks.filter((m) => m.id !== parent.activeMaskId); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
|
||||
'layers.applyMask': { description: 'Bake the active layer mask into the layer alpha and remove the mask.', run: () => { const parent = _activeParentLayer(); const mask = _getActiveMaskLayer(); if (!parent || !parent.ctx || !mask) throw new Error('no mask to apply'); _saveState('Apply layer mask'); _bakeMaskIntoLayer(parent.ctx, mask.canvas, 0, 0); parent.masks = parent.masks.filter((m) => m.id !== mask.id); parent.activeMaskId = parent.masks[0]?.id || null; composite(); _renderLayerPanel(); return true; } },
|
||||
@@ -12551,7 +12622,7 @@ function _buildEditor(container) {
|
||||
'ai.removeObject': { description: 'Remove the selected object and fill naturally. {prompt}.', run: (p) => _aiInpaintSelection((p && p.prompt) || 'remove the object, fill the area naturally') },
|
||||
'ai.harmonize': { description: 'Harmonize the layer/selection lighting with the rest of the image; result is a NEW layer. {strength}.', run: async (p) => { const { canvas } = await _aiEditCanvas('/api/image/harmonize', { strength: Number(p && p.strength) || 0.4 }); return _placeResultLayer(canvas, 'Harmonized', state.wandMask || null); } },
|
||||
'ai.generativeUpscale': { description: 'Upscale the image 2x/4x; result is a NEW layer. Falls back to a high-quality canvas upscale labelled "basic upscale" when no Real-ESRGAN model is served. {scale}.', run: async (p) => { const scale = Number(p && p.scale) === 4 ? 4 : 2; try { const { canvas } = await _aiEditCanvas('/api/image/upscale-local', { scale }); return _placeResultLayer(canvas, `Upscaled ${scale}x`, null); } catch (e) { const src = flatten(); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(src.width * scale)); c.height = Math.max(1, Math.round(src.height * scale)); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(src, 0, 0, c.width, c.height); _saveState('Upscale (basic)'); _resizeCanvasDocument(state, c.width, c.height, { anchorX: 0, anchorY: 0 }); const layerId = _placeResultLayer(c, `Upscaled ${scale}x (basic upscale)`, null); return { layerId, scale, method: 'basic', reason: String(e && e.message || e) }; } } },
|
||||
'ai.generateImage': { description: 'Generate an image from a prompt (current canvas as reference) and place it as a NEW layer. {prompt,width,height}.', run: async (p) => { const endpoint = (typeof window !== 'undefined' && window.geGenerateImageEndpoint) || '/api/image/generate'; const reference = flatten().toDataURL('image/png').split(',')[1]; const _pickedModel = (() => { try { const v = localStorage.getItem('ge-ai-command-model') || ''; return v.includes('::') ? v.split('::').pop() : ''; } catch (_) { return ''; } })(); const body = { prompt: (p && p.prompt) || '', width: Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)), height: Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)), reference, ...(_pickedModel ? { model: _pickedModel } : {}) }; let _gres = null; try { _gres = await fetch(endpoint, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (_e) { throw new Error(_generationErrorMessage({ network: true, detail: (_e && _e.message) || 'network error' })); } const res = _gres; if (!res.ok) { let _gd = res.statusText; try { const e = await res.json(); _gd = e.detail || e.error || _gd; } catch (_) {} throw new Error(_generationErrorMessage({ status: res.status, detail: _gd })); } const data = await res.json(); if (data.error) throw new Error(_generationErrorMessage({ detail: data.error })); const b64 = data.image || (Array.isArray(data.images) && data.images[0]); if (!b64) throw new Error('No image returned'); try { window.__geGenerationDisabled = 0; } catch (_) {} const img = await _decodeAIImage(b64, _aiSignal); const c = document.createElement('canvas'); c.width = state.imgWidth; c.height = state.imgHeight; c.getContext('2d').drawImage(img, 0, 0, state.imgWidth, state.imgHeight); return _placeResultLayer(c, 'Generated', state.wandMask || null); } },
|
||||
'ai.generateImage': { description: 'Generate an image with the saved generation model and fit it proportionally as a new layer. {prompt,width,height}.', run: (p) => _generateImage(p || {}) },
|
||||
'ai.generativeExpand': { description: 'Generatively expand the canvas to width×height; only the new border is inpainted, the original pixels are kept pixel-identical, and the seam is feathered. {width,height,anchor,prompt}.', run: async (p) => { const tw = Math.max(1, Math.round(Number(p && p.width) || state.imgWidth)); const th = Math.max(1, Math.round(Number(p && p.height) || state.imgHeight)); if (tw <= state.imgWidth && th <= state.imgHeight) throw new Error('target must be larger than the current canvas'); const anchor = (p && p.anchor) || 'center'; const anchorX = anchor === 'left' ? 0 : anchor === 'right' ? 1 : 0.5; const anchorY = anchor === 'top' ? 0 : anchor === 'bottom' ? 1 : 0.5; _saveState('Generative expand'); const steps = Math.max(1, Math.round(Number(p && p.steps) || 4)); const sw = state.imgWidth; const sh = state.imgHeight; _resizeCanvasDocument(state, tw, th, { anchorX, anchorY }); const flat = flatten(); const b = _contentBounds(); const mask = document.createElement('canvas'); mask.width = tw; mask.height = th; const mctx = mask.getContext('2d'); mctx.fillStyle = '#ffffff'; mctx.fillRect(0, 0, tw, th); const prefill = document.createElement('canvas'); prefill.width = tw; prefill.height = th; const pctx = prefill.getContext('2d'); pctx.imageSmoothingEnabled = true; pctx.drawImage(flat, 0, 0, tw, th); if (b) { mctx.clearRect(b.x, b.y, b.w, b.h); const bx = b.x, by = b.y, bw = b.w, bh = b.h, rx = bx + bw, by2 = by + bh; if (bx > 0) pctx.drawImage(flat, bx, by, 1, bh, 0, by, bx, bh); if (rx < tw) pctx.drawImage(flat, rx - 1, by, 1, bh, rx, by, tw - rx, bh); if (by > 0) pctx.drawImage(flat, bx, by, bw, 1, bx, 0, bw, by); if (by2 < th) pctx.drawImage(flat, bx, by2 - 1, bw, 1, bx, by2, bw, th - by2); if (bx > 0 && by > 0) pctx.drawImage(flat, bx, by, 1, 1, 0, 0, bx, by); if (rx < tw && by > 0) pctx.drawImage(flat, rx - 1, by, 1, 1, rx, 0, tw - rx, by); if (bx > 0 && by2 < th) pctx.drawImage(flat, bx, by2 - 1, 1, 1, 0, by2, bx, th - by2); if (rx < tw && by2 < th) pctx.drawImage(flat, rx - 1, by2 - 1, 1, 1, rx, by2, tw - rx, th - by2); } else { const tx = Math.round((tw - sw) * anchorX); const ty = Math.round((th - sh) * anchorY); mctx.clearRect(tx, ty, sw, sh); } const image = prefill.toDataURL('image/png').split(',')[1]; const maskB64 = mask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image, mask: maskB64, prompt: (p && p.prompt) || 'extend the scene seamlessly, continuing the surrounding content and lighting naturally', steps }) }); if (!res.ok) { let err = res.statusText; try { const e = await res.json(); err = e.detail || e.error || err; } catch (_) {} throw new Error(err); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned'); const img = await _decodeAIImage(data.image, _aiSignal); const seam = Math.max(12, Math.round(Math.min(tw, th) * 0.02)); let blendMask = _growMaskCanvas(mask, seam, true); blendMask = _featherMaskCanvas((cw, ch) => { const c = document.createElement('canvas'); c.width = cw; c.height = ch; return c; }, blendMask, Math.round(seam * 1.4)); const layer = createLayer('Generative expand', tw, th); layer.ctx.imageSmoothingEnabled = true; layer.ctx.imageSmoothingQuality = 'high'; layer.ctx.drawImage(img, 0, 0, tw, th); if (b) { const edge = _edgeBandAverage(flat, b); if (edge) { layer.ctx.save(); layer.ctx.globalAlpha = 0.14; layer.ctx.fillStyle = `rgb(${edge[0]}, ${edge[1]}, ${edge[2]})`; layer.ctx.fillRect(0, 0, tw, th); layer.ctx.restore(); } } _bakeMaskIntoLayer(layer.ctx, blendMask); state.layers.push(layer); state.activeLayerId = layer.id; state.selectedLayerIds = [layer.id]; composite(); _renderLayerPanel(); return { width: tw, height: th, layerId: layer.id, seam, steps }; } },
|
||||
'layers.rename': { description: 'Rename a layer {id|index|name, name|newName}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); const next = String(p.name || p.newName || '').trim(); if (!next) throw new Error('name required'); _saveState('Rename layer'); l.name = next; _renderLayerPanel(); return l.id; } },
|
||||
'layers.setVisibility': { description: 'Show/hide a layer {id|index|name, visible}.', run: (p) => { const l = _controlFindLayer(p) || activeLayer(); if (!l) throw new Error('layer not found'); _saveState('Toggle layer visibility'); l.visible = (p.visible === undefined) ? !l.visible : !!p.visible; composite(); _renderLayerPanel(); return l.visible; } },
|
||||
@@ -12796,6 +12867,10 @@ function _buildEditor(container) {
|
||||
endpoint: window.geEditorAgentEndpoint || '/api/editor/agent',
|
||||
getSession: () => agentBridgeSession,
|
||||
getModel: () => _getSelectedQuickEditModel(),
|
||||
getGenerationModel: () => {
|
||||
const value = document.getElementById('ge-ai-model')?.value || '';
|
||||
return value.includes('::') ? value.slice(value.indexOf('::') + 2) : '';
|
||||
},
|
||||
getAgentModel: () => _getSelectedQuickEditAgentModel(),
|
||||
});
|
||||
} catch (e) { console.error('[ge-editor] agent init failed', e); }
|
||||
@@ -13895,6 +13970,12 @@ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId)
|
||||
return;
|
||||
}
|
||||
_finishBlank(size.w, size.h);
|
||||
}).catch(error => {
|
||||
console.error('[openEditor] New project failed:', error);
|
||||
closeEditor({ force: true });
|
||||
window.dispatchEvent(new CustomEvent('gallery-editor-setup-cancelled'));
|
||||
uiModule.showError(`Could not create project: ${error?.message || 'Canvas initialization failed'}`);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14049,8 +14130,6 @@ export function closeEditor(options = {}) {
|
||||
_selectionNudgeTimer = null;
|
||||
_selectionNudgeHistorySaved = false;
|
||||
_precisionGuides = null;
|
||||
try { _preflight?.destroy(); } catch {}
|
||||
_preflight = null;
|
||||
try { _aiSettings?.destroy(); } catch {}
|
||||
_aiSettings = null;
|
||||
try { delete window.geEditorAiSettings; } catch (_) { window.geEditorAiSettings = null; }
|
||||
@@ -14061,7 +14140,20 @@ export function closeEditor(options = {}) {
|
||||
const h = state.editorDocClickHandlers.pop();
|
||||
try { document.removeEventListener('click', h); } catch {}
|
||||
}
|
||||
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
||||
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; }
|
||||
_pendingCursorEvent = null;
|
||||
_lastCursorRect = null;
|
||||
state._lastCursorDiameterX = null;
|
||||
state._lastCursorDiameterY = null;
|
||||
state._lastCursorBorderColor = null;
|
||||
if (state.cursorEl) {
|
||||
if (state.cursorEl._positionAnimation) {
|
||||
try { state.cursorEl._positionAnimation.cancel(); } catch {}
|
||||
state.cursorEl._positionAnimation = null;
|
||||
}
|
||||
state.cursorEl.remove();
|
||||
state.cursorEl = null;
|
||||
}
|
||||
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
|
||||
// Tear down all floating popups + the dock so closing the editor
|
||||
// doesn't leave stale chips/panels behind on top of the gallery.
|
||||
|
||||
+4
-2
@@ -801,7 +801,8 @@ async function _sendRoundRobin(msg, box) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'user',
|
||||
content: `[${m._groupName || m.display}]: ${response}`
|
||||
content: `[${m._groupName || m.display}]: ${response}`,
|
||||
metadata: { group_peer: true, group_model: m._groupName || m.display, model: m.mid }
|
||||
}]}),
|
||||
});
|
||||
} catch (e) { console.warn('[group] sync failed:', e); }
|
||||
@@ -828,7 +829,8 @@ async function _syncAllResponses(holders) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [{
|
||||
role: 'user',
|
||||
content: `[${model._groupName || model.display}]: ${response}`
|
||||
content: `[${model._groupName || model.display}]: ${response}`,
|
||||
metadata: { group_peer: true, group_model: model._groupName || model.display, model: model.mid }
|
||||
}]}),
|
||||
});
|
||||
} catch (e) { /* silent */ }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user