feat(picker): copy prompt, actionable errors, and a library lane worth reading
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>
This commit is contained in:
@@ -18,3 +18,7 @@ venv/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# A local convenience symlink into ComfyUI/custom_nodes — absolute, and wrong
|
||||
# on any other machine.
|
||||
/styleref-comfyui
|
||||
|
||||
@@ -32,7 +32,7 @@ git clone https://github.com/StyleRef/styleref-comfyui
|
||||
Restart ComfyUI. Nothing to install: the pack declares no Python dependencies
|
||||
and imports on the standard library alone. The Reference Images node reaches
|
||||
for Pillow and numpy when it runs, and every ComfyUI install already ships
|
||||
those. Look for `[StyleRef] v1.0.6 — 5 nodes registered` in the server console
|
||||
those. Look for `[StyleRef] v1.1.0 — 5 nodes registered` in the server console
|
||||
at startup.
|
||||
|
||||
**It does depend on styleref.io.** These nodes are a client, not a local
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
# progressive enhancement. Nothing here is required for the nodes to work.
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
_FALLBACK_VERSION = "1.0.6"
|
||||
_FALLBACK_VERSION = "1.1.0"
|
||||
|
||||
|
||||
def _pack_version() -> str:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "styleref-comfyui"
|
||||
version = "1.0.6"
|
||||
version = "1.1.0"
|
||||
description = "StyleRef nodes for ComfyUI — load a creative style once, reuse it across your workflows"
|
||||
authors = [{ name = "StyleRef", email = "hello@styleref.io" }]
|
||||
readme = "README.md"
|
||||
|
||||
+86
-3
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
@@ -39,6 +40,61 @@ def _card(entry: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# 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:
|
||||
@@ -67,9 +123,15 @@ async def styleref_search(request: web.Request) -> web.Response:
|
||||
# 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 (payload.get("styles") or [])],
|
||||
"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.
|
||||
@@ -147,8 +209,14 @@ async def styleref_my_styles(request: web.Request) -> web.Response:
|
||||
{
|
||||
"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,
|
||||
"tags": [],
|
||||
"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
|
||||
@@ -187,7 +255,20 @@ async def styleref_resolve(request: web.Request) -> web.Response:
|
||||
# 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.
|
||||
return web.json_response({"ok": False, "error": err.message})
|
||||
#
|
||||
# `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.
|
||||
@@ -236,12 +317,14 @@ async def styleref_auth_signin(_request: web.Request) -> web.Response:
|
||||
"""
|
||||
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
|
||||
|
||||
+104
-1
@@ -25,7 +25,7 @@ SITE_URL = "https://styleref.io"
|
||||
|
||||
# Identifies this surface in StyleRef's API telemetry (recordApiCall reads it).
|
||||
CLIENT_TAG = "comfyui"
|
||||
USER_AGENT = "styleref-comfyui/1.0.6 (+https://styleref.io)"
|
||||
USER_AGENT = "styleref-comfyui/1.1.0 (+https://styleref.io)"
|
||||
|
||||
DEFAULT_TIMEOUT_S = 30
|
||||
|
||||
@@ -194,6 +194,109 @@ def _raise_for_error(err: urllib.error.HTTPError) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
# ── error copy for the ComfyUI surfaces ──────────────────────────────────────
|
||||
# The REST API writes its error bodies for HTTP callers: they say to send an
|
||||
# `Authorization: Bearer` header, and they link the MCP setup guide. Neither is
|
||||
# actionable from inside ComfyUI, where the answer is always a button on a node.
|
||||
# So every surface in this pack runs the server's error through `friendly_error`
|
||||
# before showing it, and the one-line widgets use `short_error` instead.
|
||||
|
||||
DOCS_LOGIN_URL = "https://docs.styleref.io/connectors/comfyui/login-and-credits"
|
||||
|
||||
# Long enough to recognise a slug, short enough not to bury the instruction.
|
||||
_REF_PREVIEW_CHARS = 60
|
||||
|
||||
|
||||
def _ref_label(ref: str) -> str:
|
||||
ref = (ref or "").strip()
|
||||
if not ref:
|
||||
return "that style"
|
||||
shown = ref if len(ref) <= _REF_PREVIEW_CHARS else f"{ref[:_REF_PREVIEW_CHARS - 1]}…"
|
||||
return f"“{shown}”"
|
||||
|
||||
|
||||
def friendly_error(err: StyleRefError, ref: str = "") -> str:
|
||||
"""
|
||||
The server's failure, rewritten as something the user can act on *here*.
|
||||
|
||||
Every branch names a control that exists in ComfyUI. Statuses with no
|
||||
ComfyUI-specific answer (429, and anything unrecognised) keep the server's
|
||||
own wording, which is already written for a human.
|
||||
"""
|
||||
what = _ref_label(ref)
|
||||
|
||||
if err.status == 401:
|
||||
return (
|
||||
"You are not signed in to StyleRef, so this style cannot load.\n\n"
|
||||
"Click Sign in on the StyleRef Login node, or the Sign in button in "
|
||||
"the Search styles… dialog.\n"
|
||||
f"No browser on this machine? {DOCS_LOGIN_URL}"
|
||||
)
|
||||
|
||||
if err.status == 403:
|
||||
return (
|
||||
f"{what} is private, and this account cannot open it.\n\n"
|
||||
"Sign in with the account that owns it (StyleRef Login → Sign in), "
|
||||
"or load a public style by its share slug."
|
||||
)
|
||||
|
||||
if err.status == 404:
|
||||
return (
|
||||
f"No style matches {what}.\n\n"
|
||||
"Check the slug, or pick a style with the Search styles… button on "
|
||||
"the StyleRef Load node."
|
||||
)
|
||||
|
||||
if err.status == 409 and err.code == "styleref_not_generated":
|
||||
return (
|
||||
f"{what} has never been generated on styleref.io, so there is no "
|
||||
"style output to load.\n\n"
|
||||
f"Open it at {SITE_URL}/styles, generate it once, then load it here. "
|
||||
"The Search styles… dialog marks these styles “not generated”."
|
||||
)
|
||||
|
||||
if err.status == 402:
|
||||
return (
|
||||
"Your StyleRef account is out of credits.\n\n"
|
||||
f"Top up or upgrade at {SITE_URL}/pricing. Loading and applying an "
|
||||
"already-generated style costs nothing."
|
||||
)
|
||||
|
||||
if err.status is not None and err.status >= 500:
|
||||
return (
|
||||
f"StyleRef is unavailable right now (HTTP {err.status}).\n"
|
||||
"Try again in a moment — nothing on your side is wrong."
|
||||
)
|
||||
|
||||
return err.message
|
||||
|
||||
|
||||
def short_error(err: StyleRefError) -> str:
|
||||
"""
|
||||
The same failure as a widget-width label.
|
||||
|
||||
The node's read-only "loaded style" line holds one short line. Anything
|
||||
longer than this either widens the node or is cut mid-word, so the full
|
||||
text goes to `friendly_error` and never here.
|
||||
"""
|
||||
if err.status == 401:
|
||||
return "not signed in"
|
||||
if err.status == 403:
|
||||
return "private — not this account"
|
||||
if err.status == 404:
|
||||
return "not found"
|
||||
if err.status == 409 and err.code == "styleref_not_generated":
|
||||
return "⚠ never generated"
|
||||
if err.status == 402:
|
||||
return "out of credits"
|
||||
if err.status == 429:
|
||||
return "rate limited"
|
||||
if err.status is not None and err.status >= 500:
|
||||
return "StyleRef unavailable"
|
||||
# No status at all is a transport failure — DNS, a proxy, an offline box.
|
||||
return "unavailable"
|
||||
|
||||
|
||||
def request_raw(
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
|
||||
+22
-5
@@ -14,7 +14,7 @@ import re
|
||||
from typing import Any
|
||||
|
||||
import styleref_api as api
|
||||
from styleref_api import StyleRefError
|
||||
from styleref_api import StyleRefError, friendly_error
|
||||
from styleref_style import attribution, is_style, negative_terms
|
||||
|
||||
CATEGORY = "StyleRef"
|
||||
@@ -91,9 +91,16 @@ def compile_style(
|
||||
# returned on this node's own `attribution` output instead. The exceptions
|
||||
# are the document formats, where the header/structure is the point.
|
||||
raw = fmt not in ("stylemd", "json")
|
||||
text = api.get_style_text(
|
||||
ref, fmt=fmt, compact=compact, raw=raw, sections=sections or None
|
||||
).strip()
|
||||
try:
|
||||
text = api.get_style_text(
|
||||
ref, fmt=fmt, compact=compact, raw=raw, sections=sections or None
|
||||
).strip()
|
||||
except StyleRefError as err:
|
||||
# Same rewrite as Load: a session can expire between loading a style and
|
||||
# compiling it, and the raw REST 401 is no more actionable here.
|
||||
raise StyleRefError(
|
||||
friendly_error(err, ref), status=err.status, code=err.code, payload=err.payload
|
||||
) from err
|
||||
compiled[cache_key] = text
|
||||
return text
|
||||
|
||||
@@ -280,7 +287,17 @@ class StyleRefApply:
|
||||
|
||||
# ui.text: show what was actually composed, in the node body —
|
||||
# otherwise the first visible evidence of the prompt is the final image.
|
||||
#
|
||||
# `styleref_prompt` carries the *whole* composed text alongside the
|
||||
# 200-character preview. Four of the six targets exist to be copied out
|
||||
# of ComfyUI, and the node body cannot be selected; web/styleref.js reads
|
||||
# this key in onExecuted and puts it behind a Copy prompt button. A
|
||||
# frontend that ignores extra ui keys is unaffected — it renders text.
|
||||
return {
|
||||
"ui": {"text": [preview_text(positive, target, compact)]},
|
||||
"ui": {
|
||||
"text": [preview_text(positive, target, compact)],
|
||||
"styleref_prompt": [positive],
|
||||
"styleref_negative": [negative],
|
||||
},
|
||||
"result": (positive, negative, attribution(style)),
|
||||
}
|
||||
|
||||
+12
-3
@@ -24,7 +24,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
import styleref_api as api
|
||||
from styleref_api import StyleRefError
|
||||
from styleref_api import StyleRefError, friendly_error
|
||||
from styleref_style import make_style, palette_hexes, summary_line
|
||||
|
||||
CATEGORY = "StyleRef"
|
||||
@@ -112,7 +112,15 @@ def fetch_style(ref: str, use_cache: bool = True) -> dict[str, Any]:
|
||||
# "re-download every run" becomes "revalidate every run" — a bodyless 304
|
||||
# when nothing changed, the full spec when it did.
|
||||
etag = cached.get("etag") if cached else None
|
||||
spec, canonical_url, new_etag = api.get_style_spec(ref, etag=etag)
|
||||
try:
|
||||
spec, canonical_url, new_etag = api.get_style_spec(ref, etag=etag)
|
||||
except StyleRefError as err:
|
||||
# The red error window has no buttons, so the message has to name the
|
||||
# ones that exist on the nodes. Status and code are carried through so
|
||||
# /styleref/resolve can still pick a short label from them.
|
||||
raise StyleRefError(
|
||||
friendly_error(err, ref), status=err.status, code=err.code, payload=err.payload
|
||||
) from err
|
||||
if spec is None and cached is not None:
|
||||
# 304 — the edit-free style keeps its compiled-prompt cache too.
|
||||
return cached["style"]
|
||||
@@ -294,7 +302,8 @@ class StyleRefLoad:
|
||||
payload = api.save_style(ref)
|
||||
except StyleRefError as err:
|
||||
# A failed save must not sink the run — the style itself loaded.
|
||||
return f"Could not save to your library: {err.message}"
|
||||
# One line here: the node body is a summary, not an error window.
|
||||
return f"Could not save to your library ({api.short_error(err)})."
|
||||
_SAVED.add(key)
|
||||
already = payload.get("alreadySaved")
|
||||
return "Already in your library ✔" if already else "Saved to your library ✔"
|
||||
|
||||
@@ -58,10 +58,14 @@ class StyleRefReferenceImages:
|
||||
"INT",
|
||||
{
|
||||
"default": MAX_IMAGES,
|
||||
"min": 1,
|
||||
# 0 loads none: this node hangs off the side of a graph,
|
||||
# and muting it should not mean rewiring it.
|
||||
"min": 0,
|
||||
"max": MAX_IMAGES,
|
||||
"step": 1,
|
||||
"tooltip": "Load at most this many inspiration images.",
|
||||
"tooltip": "Load at most this many inspiration images. "
|
||||
"0 loads none — use it to mute this node without "
|
||||
"disconnecting it.",
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -77,6 +81,9 @@ class StyleRefReferenceImages:
|
||||
FUNCTION = "fetch"
|
||||
CATEGORY = CATEGORY
|
||||
DESCRIPTION = "The style's inspiration images as an IMAGE batch, for image conditioning."
|
||||
# ui.text only renders for an output node, and "no images, here's why" is
|
||||
# exactly the message that must not need a wired-up preview to be seen.
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def fetch(self, style: dict[str, Any], max_images: int = MAX_IMAGES):
|
||||
if not is_style(style):
|
||||
@@ -84,12 +91,22 @@ class StyleRefReferenceImages:
|
||||
"The `style` input is not a StyleRef style — connect StyleRef Load."
|
||||
)
|
||||
|
||||
urls = inspiration_image_urls(style)[: max(1, max_images)]
|
||||
urls = inspiration_image_urls(style)[: max(0, max_images)] if max_images else []
|
||||
if not urls:
|
||||
raise StyleRefError(
|
||||
f"“{style.get('name') or style.get('ref')}” has no inspiration images. "
|
||||
"Add some on styleref.io, or disconnect this node."
|
||||
# Not an error: this node feeds IPAdapter or a preview, never the
|
||||
# sampler, and a style with no inspiration images is ordinary. Killing
|
||||
# the queue over it would strand a render the user actually wanted —
|
||||
# so report it in the node body and hand back an empty batch. A
|
||||
# downstream node that genuinely requires images will say so itself.
|
||||
name = style.get("name") or style.get("ref") or "This style"
|
||||
note = (
|
||||
"max_images is 0 — no images loaded."
|
||||
if not max_images
|
||||
else f"“{name}” has no inspiration images. Add some on "
|
||||
f"{api.SITE_URL}, or leave this node unwired."
|
||||
)
|
||||
print(f"[StyleRef] {note}")
|
||||
return {"ui": {"text": [note]}, "result": (self._empty_batch(), 0)}
|
||||
|
||||
# Lazy on purpose — see the module docstring.
|
||||
import numpy as np
|
||||
@@ -119,9 +136,28 @@ class StyleRefReferenceImages:
|
||||
# IPAdapter all call `.cpu()` on it, which a bare ndarray lacks. torch
|
||||
# ships with every ComfyUI install; the numpy fallback only matters to
|
||||
# the unit tests, whose dev extras are pillow + numpy only.
|
||||
images = self._to_tensor(batch)
|
||||
summary = f"Loaded {len(frames)} inspiration image{'s' if len(frames) != 1 else ''}."
|
||||
return {"ui": {"text": [summary]}, "result": (images, len(frames))}
|
||||
|
||||
@staticmethod
|
||||
def _to_tensor(batch):
|
||||
try:
|
||||
import torch
|
||||
|
||||
return (torch.from_numpy(batch), len(frames))
|
||||
return torch.from_numpy(batch)
|
||||
except ImportError:
|
||||
return (batch, len(frames))
|
||||
return batch
|
||||
|
||||
@classmethod
|
||||
def _empty_batch(cls):
|
||||
"""
|
||||
A zero-length batch, not a black frame.
|
||||
|
||||
Preview Image and Save Image iterate the batch, so zero images renders
|
||||
as nothing — honest. A placeholder frame would look like a real
|
||||
inspiration image and quietly poison an IPAdapter run.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
return cls._to_tensor(np.zeros((0, 64, 64, 3), dtype=np.float32))
|
||||
|
||||
@@ -385,3 +385,60 @@ def test_version_strings_agree_with_pyproject():
|
||||
|
||||
pack = importlib.import_module("__init__")
|
||||
assert declared == pack._FALLBACK_VERSION
|
||||
|
||||
|
||||
# ── error copy for the ComfyUI surfaces ──────────────────────────────────────
|
||||
|
||||
|
||||
def _err(status, message="Send an Authorization: Bearer header.", code=None):
|
||||
return StyleRefError(message, status=status, code=code)
|
||||
|
||||
|
||||
def test_friendly_error_names_a_control_that_exists_in_comfyui():
|
||||
"""
|
||||
The API writes its 401 for HTTP callers: set a header, read the MCP guide.
|
||||
Neither is possible from a node graph, so the connector must not repeat it.
|
||||
"""
|
||||
text = api.friendly_error(_err(401), "abc123")
|
||||
assert "Authorization" not in text
|
||||
assert "Sign in" in text
|
||||
assert "StyleRef Login" in text
|
||||
|
||||
|
||||
def test_friendly_error_explains_a_never_generated_style():
|
||||
text = api.friendly_error(_err(409, "nope", code="styleref_not_generated"), "abc123")
|
||||
assert "never been generated" in text
|
||||
assert "abc123" in text
|
||||
|
||||
|
||||
def test_friendly_error_keeps_the_server_wording_when_it_has_nothing_better():
|
||||
"""429 already reads as a human sentence — rewriting it would lose the wait."""
|
||||
assert api.friendly_error(_err(429, "Rate limited — wait 30s and retry.")) == (
|
||||
"Rate limited — wait 30s and retry."
|
||||
)
|
||||
|
||||
|
||||
def test_friendly_error_shortens_a_long_ref():
|
||||
text = api.friendly_error(_err(404), "https://styleref.io/share/" + "x" * 200)
|
||||
assert "x" * 200 not in text
|
||||
assert "…" in text
|
||||
|
||||
|
||||
def test_short_error_fits_one_widget_line():
|
||||
"""
|
||||
The Load node's name line is one short line. Every label has to fit it —
|
||||
this is the guard that kept an API paragraph out of a 34-character widget.
|
||||
"""
|
||||
for status, code in [
|
||||
(401, None),
|
||||
(403, None),
|
||||
(404, None),
|
||||
(409, "styleref_not_generated"),
|
||||
(402, None),
|
||||
(429, None),
|
||||
(500, None),
|
||||
(418, None),
|
||||
]:
|
||||
label = api.short_error(_err(status, "a" * 400, code=code))
|
||||
assert len(label) <= 30, f"{status} label is too long: {label}"
|
||||
assert "\n" not in label
|
||||
|
||||
+94
-16
@@ -367,25 +367,73 @@ def test_private_refs_are_tagged_as_library(monkeypatch):
|
||||
assert style["url"] is None
|
||||
|
||||
|
||||
def test_ungenerated_style_error_reaches_the_user_verbatim(monkeypatch):
|
||||
def test_load_rewrites_a_never_generated_409_for_comfyui(monkeypatch):
|
||||
"""
|
||||
When the server refuses a never-generated own-style ref (409),
|
||||
its message — which tells the user to Generate on styleref.io — must pass
|
||||
through unreworded.
|
||||
The published contract says connectors render the 409 message verbatim.
|
||||
That message tells the user to open styleref.io and Generate — true, but it
|
||||
cannot mention the Search styles… dialog, which is where this user already
|
||||
saw the style marked "not generated". The connector adds that half and keeps
|
||||
the instruction; the status and code survive so the picker can still label it.
|
||||
"""
|
||||
message = (
|
||||
"This StyleRef hasn't been generated yet — open it on styleref.io and "
|
||||
"Generate it first."
|
||||
)
|
||||
|
||||
def raise_409(ref, etag=None):
|
||||
raise StyleRefError(message, status=409, code="style_not_generated")
|
||||
raise StyleRefError(
|
||||
"This StyleRef hasn't been generated yet — open it on styleref.io and "
|
||||
"Generate it first.",
|
||||
status=409,
|
||||
code="styleref_not_generated",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(api, "get_style_spec", raise_409)
|
||||
node = load_node.StyleRefLoad()
|
||||
load_node._CACHE.clear()
|
||||
with pytest.raises(StyleRefError) as caught:
|
||||
node.load("Never Generated")
|
||||
assert caught.value.message == message
|
||||
load_node.StyleRefLoad().load("Never Generated")
|
||||
|
||||
assert "never been generated" in caught.value.message
|
||||
assert "Search styles…" in caught.value.message
|
||||
assert caught.value.status == 409
|
||||
assert caught.value.code == "styleref_not_generated"
|
||||
|
||||
|
||||
def test_load_rewrites_a_401_into_something_clickable(monkeypatch):
|
||||
"""
|
||||
The exact defect this exists for: the API's 401 body reached the red error
|
||||
window telling the user to set an HTTP header. There is no header to set in
|
||||
a node graph — there is a Sign in button.
|
||||
"""
|
||||
|
||||
def raise_401(ref, etag=None):
|
||||
raise StyleRefError(
|
||||
"Sign in to StyleRef to load this style. Run `npx styleref login`. "
|
||||
"Setup guide: https://docs.styleref.io/for-ai-agents/mcp/…",
|
||||
status=401,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(api, "get_style_spec", raise_401)
|
||||
load_node._CACHE.clear()
|
||||
with pytest.raises(StyleRefError) as caught:
|
||||
load_node.StyleRefLoad().load("abc")
|
||||
|
||||
assert "StyleRef Login" in caught.value.message
|
||||
assert "npx styleref login" not in caught.value.message
|
||||
assert caught.value.status == 401, "the status must survive for /styleref/resolve"
|
||||
|
||||
|
||||
def test_apply_rewrites_an_expired_session(monkeypatch):
|
||||
"""A session can die between Load and Apply — same rewrite, same reason."""
|
||||
monkeypatch.setattr(
|
||||
api,
|
||||
"get_style_text",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
StyleRefError("Send an Authorization: Bearer header.", status=401)
|
||||
),
|
||||
)
|
||||
style = make_style(ref="abc", name="Warm")
|
||||
with pytest.raises(StyleRefError) as caught:
|
||||
apply_node.StyleRefApply().apply(style, "a lighthouse", "diffusion")
|
||||
|
||||
assert "Sign in" in caught.value.message
|
||||
assert "Authorization" not in caught.value.message
|
||||
|
||||
|
||||
def test_load_returns_ui_text_and_result_tuple(monkeypatch):
|
||||
@@ -595,7 +643,7 @@ def test_reference_images_batches_and_counts(monkeypatch):
|
||||
monkeypatch.setattr(api, "request_raw", lambda url, **k: (blobs[url], {}))
|
||||
|
||||
node = ri.StyleRefReferenceImages()
|
||||
batch, count = node.fetch(_style_with_images(list(blobs)))
|
||||
batch, count = node.fetch(_style_with_images(list(blobs)))["result"]
|
||||
|
||||
assert count == 2
|
||||
# One batch = one resolution: the first image sets it, the rest resize.
|
||||
@@ -628,11 +676,41 @@ def test_reference_images_downloads_anonymously_and_caches(monkeypatch):
|
||||
assert calls == [True], "one anonymous download, then the cache"
|
||||
|
||||
|
||||
def test_reference_images_errors_clearly_without_images():
|
||||
def test_reference_images_reports_missing_images_without_failing():
|
||||
"""
|
||||
A style with no inspiration images must not sink the queue.
|
||||
|
||||
This node feeds IPAdapter or a preview, never the sampler, so raising here
|
||||
used to stop a render the user actually wanted over a decorative branch of
|
||||
the graph. It reports in the node body and returns an empty batch instead.
|
||||
"""
|
||||
from styleref_nodes import reference_images as ri
|
||||
|
||||
with pytest.raises(StyleRefError, match="no inspiration images"):
|
||||
ri.StyleRefReferenceImages().fetch(make_style(ref="x", name="Bare"))
|
||||
out = ri.StyleRefReferenceImages().fetch(make_style(ref="x", name="Bare"))
|
||||
batch, count = out["result"]
|
||||
|
||||
assert count == 0
|
||||
assert batch.shape[0] == 0, "an empty batch, never a placeholder frame"
|
||||
assert "no inspiration images" in out["ui"]["text"][0]
|
||||
|
||||
|
||||
def test_reference_images_max_zero_mutes_the_node(monkeypatch):
|
||||
"""max_images=0 is the mute switch — no downloads, no error, no images."""
|
||||
from styleref_nodes import reference_images as ri
|
||||
|
||||
ri._IMAGE_CACHE.clear()
|
||||
monkeypatch.setattr(
|
||||
api, "request_raw", lambda *a, **k: pytest.fail("must not download when muted")
|
||||
)
|
||||
|
||||
out = ri.StyleRefReferenceImages().fetch(
|
||||
_style_with_images(["https://a/1.png"]), max_images=0
|
||||
)
|
||||
batch, count = out["result"]
|
||||
|
||||
assert count == 0
|
||||
assert batch.shape[0] == 0
|
||||
assert "max_images is 0" in out["ui"]["text"][0]
|
||||
|
||||
|
||||
# ── Load: save_to_library ────────────────────────────────────────────────────
|
||||
|
||||
+357
-71
@@ -20,7 +20,9 @@
|
||||
* status, and `npx styleref login` covers frontend-less installs);
|
||||
* - a warning badge on StyleRef Apply when `target` is a copy-out format
|
||||
* (ai_tools, style_md, midjourney, json) but the `positive` output feeds
|
||||
* a CLIP Text Encode — those are for copying out, not sampler text.
|
||||
* a text encoder — those are for copying out, not sampler text — and a
|
||||
* "Copy prompt" button, since copying out is what those four targets are
|
||||
* for and the node body only previews 200 characters.
|
||||
*
|
||||
* It creates no custom widget type and overrides no drawing beyond the badge.
|
||||
* The one widget it inserts rather than appends is flagged out of serialization
|
||||
@@ -57,6 +59,14 @@ const CATEGORIES = [
|
||||
"Content writing", "Social copy", "UX writing",
|
||||
];
|
||||
|
||||
// Own-style rows show this where a gallery row shows its author. An unknown or
|
||||
// absent value contributes nothing rather than a raw enum.
|
||||
const VISIBILITY_LABELS = {
|
||||
private: "Private",
|
||||
"link-shared": "Link-shared",
|
||||
public: "Public",
|
||||
};
|
||||
|
||||
// Rows per section page. Both sections show one page at a time with their own
|
||||
// ‹ Prev / Next ›, so the dialog stays a fixed height instead of growing.
|
||||
const PAGE_SIZE = 8;
|
||||
@@ -108,6 +118,11 @@ function el(tag, css, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// Survives between opens. Picking a style is rarely a one-shot decision —
|
||||
// people open the dialog, try one, open it again — and re-typing the query and
|
||||
// re-picking the category every time is the tax for treating each open as new.
|
||||
const pickerState = { query: "", category: "", sort: "recent" };
|
||||
|
||||
/**
|
||||
* The picker dialog: owns its query, filters, and result
|
||||
* list. Resolves with the chosen slug/ref, or null when dismissed.
|
||||
@@ -131,16 +146,60 @@ function openPicker(initialQuery) {
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
};
|
||||
/** Every result row currently on screen, in visual order. */
|
||||
const rows = () => Array.from(panel.querySelectorAll("[data-styleref-row]"));
|
||||
|
||||
const moveFocus = (delta) => {
|
||||
const list = rows();
|
||||
if (!list.length) return;
|
||||
const at = list.indexOf(document.activeElement);
|
||||
const next =
|
||||
at === -1
|
||||
? delta > 0
|
||||
? 0
|
||||
: list.length - 1
|
||||
: Math.min(list.length - 1, Math.max(0, at + delta));
|
||||
list[next].focus();
|
||||
};
|
||||
|
||||
const onKey = (e) => {
|
||||
// Esc-to-close, capturing so the canvas never sees it.
|
||||
// Capturing throughout, so the canvas never sees these: ComfyUI
|
||||
// binds arrows and Enter globally, and a search dialog that pans the
|
||||
// graph behind it while you pick a style is worse than no keyboard
|
||||
// support at all.
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
close(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
// Rows are real buttons, so Enter on a focused row already picks it.
|
||||
// This is the other half: type a query, press Enter, take the top hit.
|
||||
if (e.key === "Enter" && document.activeElement === input) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
rows()[0]?.click();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
|
||||
panel.append(el("div", "font-weight:600;margin-bottom:12px;font-size:15px;", "Pick a style"));
|
||||
// Title row. The sign-in control lives up here, next to the title,
|
||||
// because signing in changes what the whole dialog can show — it is not
|
||||
// a property of the "My styles" list, and burying it there meant the
|
||||
// signed-out state had to explain itself in a paragraph.
|
||||
const titleRow = el(
|
||||
"div",
|
||||
"display:flex;align-items:center;gap:10px;margin-bottom:12px;",
|
||||
);
|
||||
titleRow.append(el("div", "font-weight:600;font-size:15px;flex:1;", "Pick a style"));
|
||||
const authSlot = el("div", "display:flex;align-items:center;gap:8px;");
|
||||
titleRow.append(authSlot);
|
||||
panel.append(titleRow);
|
||||
|
||||
// ── controls row: query, category, sort ─────────────────────────────
|
||||
const controls = el("div", "display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;");
|
||||
@@ -150,7 +209,7 @@ function openPicker(initialQuery) {
|
||||
"border-radius:8px;padding:8px 10px;font-size:13px;outline:none;",
|
||||
);
|
||||
input.placeholder = "Search the gallery… e.g. warm editorial";
|
||||
input.value = initialQuery ?? "";
|
||||
input.value = initialQuery || pickerState.query;
|
||||
|
||||
const category = el(
|
||||
"select",
|
||||
@@ -172,22 +231,46 @@ function openPicker(initialQuery) {
|
||||
"padding:8px;font-size:13px;",
|
||||
);
|
||||
// "Saved" is a source, not an ordering: it swaps the lower section from
|
||||
// the public gallery to the styles this user saved. It sits in the same
|
||||
// the public gallery to the styles this user saved. It stays in this
|
||||
// dropdown because from the user's side all three answer one question —
|
||||
// which styles am I looking at?
|
||||
for (const [value, label] of [
|
||||
["recent", "Recent"],
|
||||
["popular", "Popular"],
|
||||
["saved", "Saved"],
|
||||
// which styles am I looking at? — but the groups say which of the two
|
||||
// things each option changes, so "Saved" no longer reads as a sort.
|
||||
for (const [groupLabel, options] of [
|
||||
["Gallery", [["recent", "Recent"], ["popular", "Popular"]]],
|
||||
["Your styles", [["saved", "Saved"]]],
|
||||
]) {
|
||||
const option = el("option", "", label);
|
||||
option.value = value;
|
||||
sort.append(option);
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = groupLabel;
|
||||
for (const [value, label] of options) {
|
||||
const option = el("option", "", label);
|
||||
option.value = value;
|
||||
group.append(option);
|
||||
}
|
||||
sort.append(group);
|
||||
}
|
||||
sort.value = pickerState.sort;
|
||||
category.value = pickerState.category;
|
||||
|
||||
controls.append(input, category, sort);
|
||||
panel.append(controls);
|
||||
|
||||
// The category filter is a gallery-search parameter; the library lane
|
||||
// is filtered by the query alone. That asymmetry is invisible until a
|
||||
// category is set and "My styles" keeps showing styles from others.
|
||||
const scopeNote = el(
|
||||
"div",
|
||||
"opacity:.45;font-size:11px;margin:-6px 2px 10px;",
|
||||
"Category narrows the gallery only — My styles is filtered by the search box.",
|
||||
);
|
||||
const syncScopeNote = () => {
|
||||
scopeNote.style.display =
|
||||
category.value && signedIn && sort.value !== "saved" ? "block" : "none";
|
||||
};
|
||||
panel.append(scopeNote);
|
||||
|
||||
// `null` until /styleref/auth/status answers — see loadAuth().
|
||||
let signedIn = null;
|
||||
|
||||
// Two independently paged sections: the user's own library on top, then
|
||||
// the gallery (or their saved styles). Separate containers so paging one
|
||||
// never re-fetches or scroll-jumps the other.
|
||||
@@ -212,26 +295,29 @@ function openPicker(initialQuery) {
|
||||
el("div", "background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;opacity:.75;font-size:13px;", text);
|
||||
|
||||
/**
|
||||
* Signed-out state, as one actionable row rather than a wall of text.
|
||||
* The old copy pasted the server's raw 401 — which told people to send an
|
||||
* `Authorization: Bearer` header and linked the MCP install page, neither
|
||||
* of which anyone can do from inside ComfyUI. Signing in is one click here.
|
||||
* The title-row sign-in control. Signed out it is a single "Sign in"
|
||||
* button — the sentence that used to explain the signed-out state was
|
||||
* the API's own 401 text, written for HTTP callers who can set an
|
||||
* Authorization header, which nobody can do from inside ComfyUI. Signed
|
||||
* in it says so quietly, so the header never looks like a call to action
|
||||
* once there is nothing to do there.
|
||||
*/
|
||||
const signInPrompt = (out, what) => {
|
||||
const box = el(
|
||||
"div",
|
||||
"background:#26262a;border-radius:8px;padding:12px;margin-bottom:6px;font-size:13px;",
|
||||
);
|
||||
box.append(
|
||||
el("div", "opacity:.8;margin-bottom:8px;", `Sign in to StyleRef to see ${what}.`),
|
||||
);
|
||||
function renderAuth() {
|
||||
authSlot.replaceChildren();
|
||||
if (signedIn === null) return; // status still in flight
|
||||
|
||||
if (signedIn) {
|
||||
authSlot.append(el("div", "opacity:.5;font-size:12px;", "Signed in"));
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = el("div", "display:flex;gap:8px;align-items:center;");
|
||||
const signIn = el(
|
||||
"button",
|
||||
"background:#4f46e5;border:0;color:#fff;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;",
|
||||
"background:#4f46e5;border:0;color:#fff;border-radius:6px;padding:6px 12px;" +
|
||||
"cursor:pointer;font-size:13px;",
|
||||
"Sign in",
|
||||
);
|
||||
signIn.title = "Sign in to load your own styles.";
|
||||
signIn.onclick = async () => {
|
||||
signIn.disabled = true;
|
||||
signIn.textContent = "Waiting for browser…";
|
||||
@@ -243,23 +329,39 @@ function openPicker(initialQuery) {
|
||||
signIn.textContent = "Sign in";
|
||||
return;
|
||||
}
|
||||
// Both sections were rendered against the signed-out state.
|
||||
renderMine();
|
||||
renderGallery();
|
||||
signedIn = true;
|
||||
renderAuth();
|
||||
// Both lanes were rendered against the signed-out state.
|
||||
renderAll();
|
||||
};
|
||||
actions.append(signIn);
|
||||
authSlot.append(signIn);
|
||||
|
||||
const help = el(
|
||||
"a",
|
||||
"color:#9b9bff;text-decoration:none;font-size:12px;opacity:.9;",
|
||||
"Setup guide",
|
||||
const hint = el("div", "opacity:.5;font-size:12px;", "to load your own styles");
|
||||
authSlot.append(hint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing matched: point at the one thing that always works from here —
|
||||
* making the style you were looking for. The link opens styleref.io
|
||||
* rather than trying to drive extraction from the canvas.
|
||||
*/
|
||||
const createPrompt = (out, text) => {
|
||||
const box = el(
|
||||
"div",
|
||||
"background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;" +
|
||||
"font-size:13px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;",
|
||||
);
|
||||
help.href = "https://docs.styleref.io/connectors/comfyui/login-and-credits";
|
||||
help.target = "_blank";
|
||||
help.rel = "noopener";
|
||||
actions.append(help);
|
||||
|
||||
box.append(actions);
|
||||
box.append(el("div", "opacity:.75;flex:1;min-width:140px;", text));
|
||||
const create = el(
|
||||
"a",
|
||||
"background:#4f46e5;color:#fff;border-radius:6px;padding:6px 12px;" +
|
||||
"text-decoration:none;font-size:13px;white-space:nowrap;",
|
||||
"Extract and create custom style",
|
||||
);
|
||||
create.href = "https://styleref.io/styles/create";
|
||||
create.target = "_blank";
|
||||
create.rel = "noopener";
|
||||
box.append(create);
|
||||
out.append(box);
|
||||
};
|
||||
|
||||
@@ -270,8 +372,23 @@ function openPicker(initialQuery) {
|
||||
"background:#26262a;border:0;color:#eee;border-radius:8px;padding:8px 10px;" +
|
||||
"margin-bottom:6px;cursor:pointer;",
|
||||
);
|
||||
// Marked for rows(), which drives arrow-key navigation.
|
||||
button.dataset.stylerefRow = "1";
|
||||
|
||||
const highlight = (on) => {
|
||||
button.style.background = on ? "#32323a" : "#26262a";
|
||||
button.style.outline = on ? "1px solid #6b6bff" : "none";
|
||||
};
|
||||
button.onmouseenter = () => (button.style.background = "#32323a");
|
||||
button.onmouseleave = () => (button.style.background = "#26262a");
|
||||
button.onmouseleave = () =>
|
||||
(button.style.background =
|
||||
document.activeElement === button ? "#32323a" : "#26262a");
|
||||
button.onfocus = () => {
|
||||
highlight(true);
|
||||
// Eight rows can outrun the panel's scroll box.
|
||||
button.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
button.onblur = () => highlight(false);
|
||||
|
||||
// Thumbnail — a fixed box so rows align with or without one.
|
||||
const thumb = el(
|
||||
@@ -287,7 +404,12 @@ function openPicker(initialQuery) {
|
||||
}
|
||||
|
||||
const text = el("div", "flex:1;min-width:0;");
|
||||
const meta = [style.author, style.category].filter(Boolean).join(" · ");
|
||||
// Own rows carry no author — they are yours — so the same line
|
||||
// carries visibility instead, which is the one thing about your own
|
||||
// style the name cannot tell you.
|
||||
const meta = [style.author, VISIBILITY_LABELS[style.visibility], style.category]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const nameRow = el("div", "display:flex;align-items:center;gap:6px;min-width:0;");
|
||||
nameRow.append(
|
||||
el(
|
||||
@@ -385,6 +507,14 @@ function openPicker(initialQuery) {
|
||||
async function renderMine() {
|
||||
const id = ++mineRequest;
|
||||
const query = input.value.trim();
|
||||
|
||||
// Signed out there is no library to page, and an empty labelled
|
||||
// section is just a header explaining its own emptiness — the Sign
|
||||
// in button in the title row already says what to do about it.
|
||||
if (signedIn === false) {
|
||||
mineBox.replaceChildren();
|
||||
return;
|
||||
}
|
||||
section(mineBox, "My styles", (out) => out.append(message("Loading…")));
|
||||
|
||||
const cursor = mineCursors[minePage] ?? null;
|
||||
@@ -400,17 +530,31 @@ function openPicker(initialQuery) {
|
||||
if (mine.nextCursor) mineCursors[minePage + 1] = mine.nextCursor;
|
||||
else mineCursors.length = minePage + 1;
|
||||
|
||||
// A 401 mid-flight means the stored session died; fall back to the
|
||||
// signed-out layout rather than showing the lane's own error.
|
||||
if (mine.signedIn === false) {
|
||||
signedIn = false;
|
||||
renderAuth();
|
||||
mineBox.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
// Page 1 with nothing on it: hide the lane. Whether the library is
|
||||
// empty or just filtered to nothing, the gallery below is the answer,
|
||||
// and it already offers "create a style" when it comes up empty too.
|
||||
if (!mine.error && !(mine.styles || []).length && minePage === 0) {
|
||||
mineBox.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
section(mineBox, "My styles", (out) => {
|
||||
if (mine.signedIn === false) {
|
||||
signInPrompt(out, "your style library");
|
||||
return;
|
||||
}
|
||||
if (mine.error) {
|
||||
out.append(message(mine.error));
|
||||
return;
|
||||
}
|
||||
const styles = mine.styles || [];
|
||||
if (!styles.length) {
|
||||
// Only reachable past page 1 — keep the pager so Prev exists.
|
||||
out.append(
|
||||
message(
|
||||
query
|
||||
@@ -418,8 +562,6 @@ function openPicker(initialQuery) {
|
||||
: "No styles in your library yet.",
|
||||
),
|
||||
);
|
||||
// A filtered-to-nothing page 2+ still needs a way back.
|
||||
if (minePage === 0) return;
|
||||
}
|
||||
for (const style of styles) out.append(row(style));
|
||||
|
||||
@@ -464,7 +606,10 @@ function openPicker(initialQuery) {
|
||||
|
||||
section(galleryBox, title, (out) => {
|
||||
if (gallery.signedIn === false) {
|
||||
signInPrompt(out, "the styles you saved");
|
||||
// "Saved" is the only signed-in source down here.
|
||||
signedIn = false;
|
||||
renderAuth();
|
||||
out.append(message("Sign in to load your own styles."));
|
||||
return;
|
||||
}
|
||||
if (gallery.error) {
|
||||
@@ -474,16 +619,15 @@ function openPicker(initialQuery) {
|
||||
}
|
||||
const styles = gallery.styles || [];
|
||||
if (!styles.length) {
|
||||
out.append(
|
||||
message(
|
||||
saved
|
||||
? query
|
||||
? `None of your saved styles match "${query}".`
|
||||
: "You haven't saved any gallery styles yet."
|
||||
: query
|
||||
? `No styles matched "${query}". Try a broader query.`
|
||||
: "No styles found. Browse styleref.io/gallery",
|
||||
),
|
||||
createPrompt(
|
||||
out,
|
||||
saved
|
||||
? query
|
||||
? `None of your saved styles match "${query}".`
|
||||
: "You haven't saved any gallery styles yet."
|
||||
: query
|
||||
? `No styles matched "${query}".`
|
||||
: "No styles found.",
|
||||
);
|
||||
if (galleryPage === 0) return;
|
||||
}
|
||||
@@ -506,12 +650,31 @@ function openPicker(initialQuery) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth state for the whole dialog: `null` until the status call lands,
|
||||
* so neither the header nor the lanes flash a signed-out layout at a
|
||||
* signed-in user.
|
||||
*/
|
||||
async function loadAuth() {
|
||||
const status = await fetchJson("/styleref/auth/status");
|
||||
signedIn = status.signedIn === true;
|
||||
renderAuth();
|
||||
syncScopeNote(); // the note only applies while a library lane exists
|
||||
renderMine();
|
||||
}
|
||||
|
||||
/** A changed filter invalidates both lanes' paging — restart at page 1. */
|
||||
function renderAll() {
|
||||
pickerState.query = input.value.trim();
|
||||
pickerState.category = category.value;
|
||||
pickerState.sort = sort.value;
|
||||
syncScopeNote();
|
||||
minePage = 0;
|
||||
mineCursors = [null];
|
||||
galleryPage = 0;
|
||||
renderMine();
|
||||
// Before the status call lands, loadAuth() owns the first render of
|
||||
// this lane — calling it here too would just fetch the same page twice.
|
||||
if (signedIn !== null) renderMine();
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
@@ -532,6 +695,7 @@ function openPicker(initialQuery) {
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
input.focus();
|
||||
loadAuth();
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
@@ -639,6 +803,25 @@ function setStatus(node, widget, text) {
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a widget without removing it from `node.widgets`.
|
||||
*
|
||||
* `widgets_values` is a positional array, so splicing a widget out of the list
|
||||
* would shift every value after it and reload a saved workflow with the wrong
|
||||
* fields — the same trap documented on the name widget below. Hiding leaves the
|
||||
* array intact, so the value still serialises and a frontend without the search
|
||||
* dialog still shows the field.
|
||||
*
|
||||
* `hidden` is what current frontends read; the zero `computeSize` is the older
|
||||
* LiteGraph path. A frontend that honours neither shows the widget, which is
|
||||
* exactly today's behaviour — this degrades to a no-op, never to a broken node.
|
||||
*/
|
||||
function hideWidget(widget) {
|
||||
if (!widget) return;
|
||||
widget.hidden = true;
|
||||
widget.computeSize = () => [0, -4];
|
||||
}
|
||||
|
||||
const NAME_LABEL = "loaded style";
|
||||
const NAME_IDLE = "—";
|
||||
|
||||
@@ -680,15 +863,32 @@ function addStyleNameWidget(node) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
/** The widget's value. Truncated so a long name cannot widen the node. */
|
||||
// Every value this widget can hold is cut to this. It is one line under
|
||||
// `style_ref`, and a longer string either widens the node or is cut mid-word.
|
||||
const NAME_MAX_CHARS = 34;
|
||||
|
||||
function clampName(text) {
|
||||
const value = String(text ?? "").trim();
|
||||
return value.length > NAME_MAX_CHARS ? `${value.slice(0, NAME_MAX_CHARS - 1)}…` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The widget's value — always short, on the failure path as well as the good one.
|
||||
*
|
||||
* The failure path used to return the server's `message`, which is a paragraph
|
||||
* of sign-in instructions written for HTTP callers. It arrived here uncut,
|
||||
* because the length guard sat on the success branch only, and stretched the
|
||||
* node. `short` is the server-side label for the same failure (`short_error`
|
||||
* in styleref_api.py); the full text still exists, for the surfaces that have
|
||||
* room for it.
|
||||
*/
|
||||
function styleNameValue(res) {
|
||||
if (!res || res.ok !== true) {
|
||||
// A half-typed slug is the common case here, not a failure worth
|
||||
// shouting about — the node still queues and reports properly.
|
||||
return res?.error ? res.error : "not found";
|
||||
return clampName(res?.short || "not found");
|
||||
}
|
||||
const name = String(res.name ?? "").trim() || "Untitled style";
|
||||
return name.length > 34 ? `${name.slice(0, 33)}…` : name;
|
||||
return clampName(res.name || "Untitled style");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -720,6 +920,12 @@ async function refreshStyleName(node, nameWidget) {
|
||||
.trim();
|
||||
if (current !== ref) return;
|
||||
setStyleName(node, nameWidget, styleNameValue(res));
|
||||
// The picker marks a never-generated style on its row; queueing one is a
|
||||
// 409. Carrying the same warning here means the node says so while the
|
||||
// user is still looking at the field, not after the run fails.
|
||||
nameWidget.tooltip = res?.notGenerated
|
||||
? "Never generated on styleref.io — generate it once there, then load it here."
|
||||
: res?.error || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -761,7 +967,14 @@ async function refreshLoginStatus(node, statusWidget) {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when Apply's `positive` output feeds a CLIPTextEncode. */
|
||||
// Every prompt encoder in circulation, not just the stock one: SDXL, SD3 and
|
||||
// Flux ship their own classes, and half the custom packs wrap them. Matching
|
||||
// the exact string "CLIPTextEncode" meant the warning quietly did nothing on
|
||||
// most real graphs. Substring matching can over-fire; a badge that appears once
|
||||
// too often is a far cheaper mistake than one that never appears.
|
||||
const TEXT_ENCODER_PATTERN = /textencode|textencoder|encodetext/i;
|
||||
|
||||
/** True when Apply's `positive` output feeds a text encoder. */
|
||||
function positiveFeedsClipEncode(node) {
|
||||
const graph = node.graph;
|
||||
const output = node.outputs?.find((o) => o.name === "positive");
|
||||
@@ -769,10 +982,53 @@ function positiveFeedsClipEncode(node) {
|
||||
return output.links.some((linkId) => {
|
||||
const link = graph.links?.[linkId];
|
||||
const target = link ? graph.getNodeById(link.target_id) : null;
|
||||
return target?.type === "CLIPTextEncode";
|
||||
return TEXT_ENCODER_PATTERN.test(target?.type ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Apply node's Copy prompt button.
|
||||
*
|
||||
* Four of the six targets — ai_tools, style_md, midjourney, json — exist to be
|
||||
* copied *out* of ComfyUI, and until now the only way to read one in full was to
|
||||
* wire up a Preview Any node: the node body shows 200 characters and cannot be
|
||||
* selected. The Python side sends the whole composed text in the execution
|
||||
* payload (`styleref_prompt`), which is where this reads it from.
|
||||
*/
|
||||
const COPY_LABEL = "📋 Copy prompt";
|
||||
|
||||
function enhanceApplyNode(node) {
|
||||
let prompt = "";
|
||||
|
||||
const button = node.addWidget("button", COPY_LABEL, null, async () => {
|
||||
if (!prompt) {
|
||||
showMessage(
|
||||
"Nothing composed yet — queue this node once, then copy.\n\n" +
|
||||
"The button copies the same text the `positive` output carries.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Secure context only. ComfyUI on plain http:// over a LAN is not
|
||||
// one, and a rejected promise here would leave the user with no
|
||||
// text and no explanation.
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
setStatus(node, button, "✔ Copied");
|
||||
setTimeout(() => setStatus(node, button, COPY_LABEL), 1500);
|
||||
} catch {
|
||||
// The fallback is not a lesser copy — it is the text itself, in a
|
||||
// window the user can select from.
|
||||
showMessage(prompt);
|
||||
}
|
||||
});
|
||||
|
||||
const onExecuted = node.onExecuted;
|
||||
node.onExecuted = function (message, ...rest) {
|
||||
prompt = message?.styleref_prompt?.[0] ?? prompt;
|
||||
return onExecuted?.apply(this, [message, ...rest]);
|
||||
};
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "styleref.load.search",
|
||||
|
||||
@@ -799,6 +1055,17 @@ app.registerExtension({
|
||||
// Only add the search button when the backend routes exist
|
||||
// — otherwise it could only 404.
|
||||
if (routesAvailable !== false) {
|
||||
// `search` + `category` are the headless fallback: type a
|
||||
// query, queue the graph, read slugs out of the node body,
|
||||
// paste one into `style_ref`. With the dialog present they
|
||||
// are a second, slower path to the same result sitting right
|
||||
// next to the button — the same duplication the Login node's
|
||||
// `action` dropdown was removed for. Hidden, not deleted:
|
||||
// they still serialise, and a frontend that cannot run this
|
||||
// file never hides them.
|
||||
hideWidget(this.widgets?.find((w) => w.name === "search"));
|
||||
hideWidget(this.widgets?.find((w) => w.name === "category"));
|
||||
|
||||
this.addWidget("button", "Search styles…", null, async () => {
|
||||
const refWidget = this.widgets?.find((w) => w.name === "style_ref");
|
||||
const searchWidget = this.widgets?.find((w) => w.name === "search");
|
||||
@@ -809,6 +1076,17 @@ app.registerExtension({
|
||||
|
||||
refWidget.value = chosen;
|
||||
refWidget.callback?.(chosen);
|
||||
|
||||
// Clear the fallback query. A leftover value makes every
|
||||
// later execution of this node run a gallery search it
|
||||
// does not need, and print those results into the node
|
||||
// body under the load summary — for the life of the
|
||||
// saved workflow, on a field that is now hidden.
|
||||
if (searchWidget && searchWidget.value) {
|
||||
searchWidget.value = "";
|
||||
searchWidget.callback?.("");
|
||||
}
|
||||
|
||||
this.setDirtyCanvas(true, true);
|
||||
refreshStyleName(this, nameWidget);
|
||||
});
|
||||
@@ -841,9 +1119,17 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
if (nodeData?.name === APPLY_NODE) {
|
||||
// Warning badge: `natural`/`style_md` are copy-out
|
||||
// documents; wiring one into a CLIP encoder is almost certainly a
|
||||
// mistake. Additive drawing only — nothing else is touched.
|
||||
const onApplyCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onApplyCreated?.apply(this, arguments);
|
||||
if (routesAvailable !== false) enhanceApplyNode(this);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Warning badge: `ai_tools`, `style_md`, `midjourney` and `json` are
|
||||
// copy-out formats, not sampler text; wiring one into a text encoder
|
||||
// is almost certainly a mistake. Additive drawing only — nothing
|
||||
// else is touched.
|
||||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||||
onDrawForeground?.apply(this, arguments);
|
||||
@@ -852,7 +1138,7 @@ app.registerExtension({
|
||||
if (!COPY_OUT_TARGETS.includes(target?.value) || !positiveFeedsClipEncode(this))
|
||||
return;
|
||||
|
||||
const label = `⚠ ${target.value} target → CLIP encoder`;
|
||||
const label = `⚠ ${target.value} target → text encoder`;
|
||||
ctx.save();
|
||||
ctx.font = "11px system-ui, sans-serif";
|
||||
const width = ctx.measureText(label).width + 14;
|
||||
|
||||
Reference in New Issue
Block a user