Release 1.1.0. Everything here came out of using the style picker and finding the same shape of problem in several places: the connector showed the REST API's own words to someone who cannot act on them, or showed nothing at all. Errors, in one place. `friendly_error`/`short_error` (styleref_api.py) rewrite an API failure as something a node graph can act on — the Login node's Sign in button, the Search styles… dialog, generate-it-once for a 409 — and Load and Apply re-raise through them. The raw 401 told users to send an `Authorization: Bearer` header into ComfyUI's red error window, which has no buttons at all. `short_error` is the same failure at widget width; the "loaded style" line used to render the whole paragraph uncut, because the length guard sat on the success branch only. The picker: - Sign in moved to the title row. It changes what the whole dialog can show, so it is not a property of one list. - "My styles" hides itself when empty, and its rows now carry the thumbnail, category and tags a gallery row carries — with visibility (Private / Link-shared / Public) where a gallery row carries its author. A name on a blank square sat directly above real cards. - The gallery lane drops styles you own, so a published style of yours appears once rather than in both lanes. - No matches now offers /styles/create instead of a dead end. - ↓/↑ walk the rows, Enter takes the top hit, Esc closes; query, category and sort survive between opens. Load hides `search`/`category` when the dialog is available — they are the headless fallback, and next to a working button they are the same duplication the Login node's `action` dropdown was removed for. Picking also clears the query, which otherwise made every later execution run a search it did not need. Apply gains a Copy prompt button. Four of its six targets exist to be copied out of ComfyUI, and the only way to read one in full was to wire up a Preview Any node. The warning badge now matches any text encoder, not the exact class `CLIPTextEncode` — SDXL, SD3 and Flux ship their own, so it did nothing on most real graphs. Reference Images no longer fails the queue when a style has no inspiration images: it feeds IPAdapter or a preview, never the sampler, so it reports and returns an empty batch. `max_images: 0` mutes it without rewiring. Needs the matching StyleRef-Webapp deploy: the library lane's category, tags and heroImage come from /api/v1/me/styles, and its search now scores the same fields the gallery search scores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""
|
|
Backend routes for the Load node's search dialog.
|
|
|
|
The browser cannot call styleref.io directly from the ComfyUI frontend (CORS
|
|
aside, an auth token must never reach page JS), so the widget asks the ComfyUI
|
|
server, which asks StyleRef using the same client the nodes use.
|
|
|
|
Everything here is optional. If ComfyUI's server module isn't importable — a
|
|
headless test run, an API-only embed, a future frontend rewrite — importing this
|
|
fails, `__init__` catches it, and the nodes carry on with the plain text field.
|
|
The JS side probes /styleref/search once at startup and hides its button when
|
|
these routes are absent, so both halves degrade together.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
|
|
from aiohttp import web
|
|
from server import PromptServer
|
|
|
|
import styleref_api as api
|
|
from styleref_api import StyleRefError
|
|
from styleref_auth import auth_status, clear_credentials, credentials_path, run_login_flow
|
|
from styleref_nodes.load import bump_refresh, fetch_style
|
|
|
|
|
|
def _card(entry: dict) -> dict:
|
|
"""The picker-row shape. heroImage feeds the row thumbnail."""
|
|
return {
|
|
"slug": entry.get("slug"),
|
|
"name": entry.get("name"),
|
|
"author": entry.get("author"),
|
|
"category": entry.get("category"),
|
|
"tags": (entry.get("tags") or [])[:4],
|
|
"heroImage": entry.get("heroImage"),
|
|
"url": entry.get("url"),
|
|
}
|
|
|
|
|
|
# Slugs of the signed-in user's own published styles. A style of yours that is
|
|
# in the gallery is in *both* lanes of the picker otherwise — the same row
|
|
# twice, once under "My styles" and once under "Gallery" — and deduping in the
|
|
# frontend only works for the page currently on screen. Walked once and cached:
|
|
# the answer changes when you publish something, not between keystrokes.
|
|
_OWN_SLUGS: dict[str, object] = {"at": 0.0, "slugs": set()}
|
|
_OWN_SLUGS_TTL_S = 300
|
|
_OWN_SLUGS_PAGE = 50
|
|
_OWN_SLUGS_MAX = 200
|
|
|
|
|
|
def forget_own_slugs() -> None:
|
|
"""Sign-in and sign-out both change whose library this is."""
|
|
_OWN_SLUGS["at"] = 0.0
|
|
_OWN_SLUGS["slugs"] = set()
|
|
|
|
|
|
def own_slugs() -> set[str]:
|
|
"""
|
|
Every share slug this account owns, or an empty set when signed out.
|
|
|
|
Only published styles have a slug, and only a slug can collide with a
|
|
gallery row, so an unpublished library costs one request and returns
|
|
nothing to filter.
|
|
"""
|
|
signed_in, _message = auth_status()
|
|
if not signed_in:
|
|
return set()
|
|
|
|
now = time.monotonic()
|
|
if now - float(_OWN_SLUGS["at"]) < _OWN_SLUGS_TTL_S:
|
|
return _OWN_SLUGS["slugs"] # type: ignore[return-value]
|
|
|
|
slugs: set[str] = set()
|
|
cursor: str | None = None
|
|
try:
|
|
while len(slugs) < _OWN_SLUGS_MAX:
|
|
payload = api.list_my_styles(limit=_OWN_SLUGS_PAGE, cursor=cursor)
|
|
for entry in payload.get("styles") or []:
|
|
slug = entry.get("slug")
|
|
if slug:
|
|
slugs.add(slug)
|
|
cursor = payload.get("nextCursor")
|
|
if not cursor:
|
|
break
|
|
except StyleRefError:
|
|
# Filtering is a nicety; a failure here must not break search. An empty
|
|
# set means the gallery lane is unfiltered, which is the old behaviour.
|
|
return set()
|
|
|
|
_OWN_SLUGS["at"] = now
|
|
_OWN_SLUGS["slugs"] = slugs
|
|
return slugs
|
|
|
|
|
|
def _int_param(request: web.Request, name: str, default: int) -> int:
|
|
"""A query int that tolerates junk — a bad value is a default, not a 400."""
|
|
try:
|
|
return int(request.query.get(name, str(default)))
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
@PromptServer.instance.routes.get("/styleref/search")
|
|
async def styleref_search(request: web.Request) -> web.Response:
|
|
"""GET /styleref/search?query=…&category=…&sort=…&limit=…&offset=… → {styles, hasMore}"""
|
|
query = request.query.get("query", "").strip()
|
|
category = request.query.get("category", "").strip() or None
|
|
sort = request.query.get("sort", "").strip() or None
|
|
if sort not in (None, "recent", "popular"):
|
|
sort = None
|
|
limit = _int_param(request, "limit", 8)
|
|
offset = _int_param(request, "offset", 0)
|
|
|
|
try:
|
|
payload = api.search_styles(
|
|
query, category=category, sort=sort, limit=limit, offset=offset
|
|
)
|
|
except StyleRefError as err:
|
|
# 200 with an error field: the dialog renders the message inline rather
|
|
# than the frontend surfacing a generic red toast with no detail.
|
|
return web.json_response({"styles": [], "error": err.message})
|
|
|
|
# The user's own styles already have their own lane above this one. A page
|
|
# can come back one or two rows short as a result — cheaper than showing the
|
|
# same style twice and letting the user wonder which one to click.
|
|
mine = own_slugs()
|
|
rows = [s for s in (payload.get("styles") or []) if s.get("slug") not in mine]
|
|
|
|
return web.json_response(
|
|
{
|
|
"styles": [_card(s) for s in rows],
|
|
"availableCategories": payload.get("availableCategories") or [],
|
|
# Drives the dialog's Next button without it having to guess from a
|
|
# short page — a full last page is indistinguishable otherwise.
|
|
"hasMore": bool(payload.get("hasMore")),
|
|
}
|
|
)
|
|
|
|
|
|
@PromptServer.instance.routes.get("/styleref/saved")
|
|
async def styleref_saved(request: web.Request) -> web.Response:
|
|
"""
|
|
The picker's "Saved" sort: gallery styles this user saved. Signed-in only,
|
|
and reported as such rather than as an error — signing out is not a failure.
|
|
"""
|
|
signed_in, message = auth_status()
|
|
if not signed_in:
|
|
return web.json_response({"styles": [], "signedIn": False, "message": message})
|
|
|
|
query = request.query.get("query", "").strip()
|
|
category = request.query.get("category", "").strip() or None
|
|
|
|
try:
|
|
payload = api.list_saved_styles(
|
|
query,
|
|
category=category,
|
|
limit=_int_param(request, "limit", 8),
|
|
offset=_int_param(request, "offset", 0),
|
|
)
|
|
except StyleRefError as err:
|
|
# A 401 here means stored credentials that no longer work (expired, or
|
|
# revoked on styleref.io). That is the signed-out state as far as the UI
|
|
# is concerned, so report it as one and let the dialog offer Sign in —
|
|
# echoing the server's raw 401 told people to set an Authorization header,
|
|
# which is not something anyone can do from inside ComfyUI.
|
|
if err.needs_login:
|
|
return web.json_response({"styles": [], "signedIn": False})
|
|
return web.json_response({"styles": [], "signedIn": True, "error": err.message})
|
|
|
|
return web.json_response(
|
|
{
|
|
"signedIn": True,
|
|
"styles": [_card(s) for s in (payload.get("styles") or [])],
|
|
"hasMore": bool(payload.get("hasMore")),
|
|
}
|
|
)
|
|
|
|
|
|
@PromptServer.instance.routes.get("/styleref/my-styles")
|
|
async def styleref_my_styles(request: web.Request) -> web.Response:
|
|
"""The caller's own library, shown above gallery results when signed in."""
|
|
signed_in, message = auth_status()
|
|
if not signed_in:
|
|
return web.json_response({"styles": [], "signedIn": False, "message": message})
|
|
|
|
cursor = request.query.get("cursor", "").strip() or None
|
|
query = request.query.get("query", "").strip()
|
|
try:
|
|
payload = api.list_my_styles(
|
|
limit=_int_param(request, "limit", 8), cursor=cursor, query=query
|
|
)
|
|
except StyleRefError as err:
|
|
# A 401 here means stored credentials that no longer work (expired, or
|
|
# revoked on styleref.io). That is the signed-out state as far as the UI
|
|
# is concerned, so report it as one and let the dialog offer Sign in —
|
|
# echoing the server's raw 401 told people to set an Authorization header,
|
|
# which is not something anyone can do from inside ComfyUI.
|
|
if err.needs_login:
|
|
return web.json_response({"styles": [], "signedIn": False})
|
|
return web.json_response({"styles": [], "signedIn": True, "error": err.message})
|
|
|
|
return web.json_response(
|
|
{
|
|
"signedIn": True,
|
|
"styles": [
|
|
{
|
|
"slug": s.get("ref"),
|
|
"name": s.get("name"),
|
|
# No author line: this is your own library, and the row
|
|
# shows visibility there instead — the one fact about your
|
|
# own style you cannot see from its name.
|
|
"author": None,
|
|
"category": s.get("category"),
|
|
"tags": (s.get("tags") or [])[:4],
|
|
"heroImage": s.get("heroImage"),
|
|
"url": s.get("url"),
|
|
"visibility": s.get("visibility"),
|
|
# null → never generated on styleref.io, so loading it would
|
|
# 409. The dialog badges these instead of offering them as
|
|
# ready to use.
|
|
"lastGeneratedAt": s.get("lastGeneratedAt"),
|
|
}
|
|
for s in (payload.get("styles") or [])
|
|
],
|
|
# Passed back as ?cursor= for the dialog's Next button.
|
|
"nextCursor": payload.get("nextCursor"),
|
|
}
|
|
)
|
|
|
|
|
|
@PromptServer.instance.routes.get("/styleref/resolve")
|
|
async def styleref_resolve(request: web.Request) -> web.Response:
|
|
"""
|
|
GET /styleref/resolve?ref=… → {ok, name, url}
|
|
|
|
Backs the name line under the Load node's `style_ref` field. A slug is
|
|
opaque — `72e1zdae-e2d54a18d090` tells you nothing about which style is
|
|
wired in, and until the graph is queued the node cannot say either. This
|
|
answers the question the moment the field changes.
|
|
|
|
It goes through the same client (and therefore the same session cache) the
|
|
node uses, so a resolve for a style that is about to be loaded costs one
|
|
request and the queue after it costs none.
|
|
"""
|
|
ref = request.query.get("ref", "").strip()
|
|
if not ref:
|
|
return web.json_response({"ok": False})
|
|
|
|
try:
|
|
style = fetch_style(ref)
|
|
except StyleRefError as err:
|
|
# 200 with an error field, as with search: an unresolvable ref is
|
|
# ordinary (a half-typed slug, a private style while signed out) and
|
|
# belongs inline on the node, not in a red toast.
|
|
#
|
|
# `short` is what the widget actually shows. The line under `style_ref`
|
|
# holds one short line, and `message` is a paragraph — rendering it there
|
|
# is how the API's sign-in instructions used to end up stretched across
|
|
# a node. `notGenerated` lets the widget carry the same warning the
|
|
# picker puts on the row, before the queue turns it into a 409.
|
|
return web.json_response(
|
|
{
|
|
"ok": False,
|
|
"error": err.message,
|
|
"short": api.short_error(err),
|
|
"notGenerated": err.status == 409 and err.code == "styleref_not_generated",
|
|
}
|
|
)
|
|
|
|
# No author: the structured lane carries name/sections/referenceImages only,
|
|
# so the node body's attribution line is where a handle would come from.
|
|
return web.json_response(
|
|
{"ok": True, "name": style.get("name"), "url": style.get("url")}
|
|
)
|
|
|
|
|
|
@PromptServer.instance.routes.post("/styleref/refresh")
|
|
async def styleref_refresh(request: web.Request) -> web.Response:
|
|
"""
|
|
The ↻ Refresh style button's backend: forget the cached style and advance
|
|
the generation counter IS_CHANGED reads, so the next queue re-downloads.
|
|
"""
|
|
try:
|
|
payload = await request.json()
|
|
except Exception: # noqa: BLE001 - malformed body is a client bug, not a crash
|
|
payload = {}
|
|
ref = str(payload.get("ref") or "").strip()
|
|
if not ref:
|
|
return web.json_response({"ok": False, "error": "No style_ref to refresh."})
|
|
generation = bump_refresh(ref)
|
|
return web.json_response({"ok": True, "generation": generation})
|
|
|
|
|
|
# ── auth: the Login node's button backend ────────────────────────────────────
|
|
# The Login node's `action` dropdown stays as the queue-driven fallback (it is
|
|
# the only path for API-only installs); these routes power the node's buttons.
|
|
|
|
|
|
def _status_payload() -> dict:
|
|
signed_in, message = auth_status()
|
|
return {"signedIn": signed_in, "message": message}
|
|
|
|
|
|
@PromptServer.instance.routes.get("/styleref/auth/status")
|
|
async def styleref_auth_status(_request: web.Request) -> web.Response:
|
|
return web.json_response(_status_payload())
|
|
|
|
|
|
@PromptServer.instance.routes.post("/styleref/auth/signin")
|
|
async def styleref_auth_signin(_request: web.Request) -> web.Response:
|
|
"""
|
|
Run the OAuth loopback flow. Blocking by nature (it waits for the browser
|
|
redirect), so it runs in an executor; the JS button shows a waiting state.
|
|
"""
|
|
loop = asyncio.get_event_loop()
|
|
ok, message = await loop.run_in_executor(None, run_login_flow, api.api_base())
|
|
forget_own_slugs()
|
|
return web.json_response({"ok": ok, "message": message, **_status_payload()})
|
|
|
|
|
|
@PromptServer.instance.routes.post("/styleref/auth/signout")
|
|
async def styleref_auth_signout(_request: web.Request) -> web.Response:
|
|
removed = clear_credentials()
|
|
forget_own_slugs()
|
|
message = (
|
|
f"Signed out — removed {credentials_path()}."
|
|
if removed
|
|
else "No stored credentials to remove."
|
|
)
|
|
if os.environ.get("STYLEREF_TOKEN"):
|
|
message += (
|
|
" Note: STYLEREF_TOKEN is still set in this environment and takes "
|
|
"precedence, so you remain signed in via that token."
|
|
)
|
|
return web.json_response({"ok": True, "message": message, **_status_payload()})
|