Skip to content

Commit fdf9290

Browse files
committed
feat: implement RemoteComboOptions for rich remote-populated combos
Signed-off-by: bigcat88 <bigcat88@icloud.com>
1 parent e9a2d1e commit fdf9290

3 files changed

Lines changed: 346 additions & 0 deletions

File tree

comfy_api/latest/_io.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from enum import Enum
1010
from typing import Any, Callable, Literal, TypedDict, TypeVar, TYPE_CHECKING
1111
from typing_extensions import NotRequired, final
12+
from urllib.parse import urlparse
1213

1314
# used for type hinting
1415
import torch
@@ -43,7 +44,67 @@ class UploadType(str, Enum):
4344
model = "file_upload"
4445

4546

47+
class RemoteItemSchema:
48+
"""Describes how to map API response objects to rich dropdown items.
49+
50+
All *_field parameters use dot-path notation (e.g. ``"labels.gender"``).
51+
``label_field`` and ``description_field`` additionally support template strings
52+
with ``{field}`` placeholders (e.g. ``"{name} ({labels.accent})"``).
53+
"""
54+
def __init__(
55+
self,
56+
value_field: str,
57+
label_field: str,
58+
preview_url_field: str | None = None,
59+
preview_type: Literal["image", "video", "audio"] = "image",
60+
description_field: str | None = None,
61+
search_fields: list[str] | None = None,
62+
):
63+
if preview_type not in ("image", "video", "audio"):
64+
raise ValueError(
65+
f"RemoteItemSchema: 'preview_type' must be 'image', 'video', or 'audio'; got {preview_type!r}."
66+
)
67+
if search_fields is not None:
68+
for f in search_fields:
69+
if "{" in f or "}" in f:
70+
raise ValueError(
71+
f"RemoteItemSchema: 'search_fields' must be dot-paths, not template strings (got {f!r})."
72+
)
73+
self.value_field = value_field
74+
"""Dot-path to the unique identifier within each item.
75+
This value is stored in the widget and passed to execute()."""
76+
self.label_field = label_field
77+
"""Dot-path to the display name, or a template string with {field} placeholders."""
78+
self.preview_url_field = preview_url_field
79+
"""Dot-path to a preview media URL. If None, no preview is shown."""
80+
self.preview_type = preview_type
81+
"""How to render the preview: "image", "video", or "audio"."""
82+
self.description_field = description_field
83+
"""Optional dot-path or template for a subtitle line shown below the label."""
84+
self.search_fields = search_fields
85+
"""Dot-paths to fields included in the search index. When unset, search falls back to
86+
the resolved label (i.e. ``label_field`` after template substitution). Note that template
87+
label strings (e.g. ``"{first} {last}"``) are not valid path entries here — list the
88+
underlying paths (``["first", "last"]``) instead."""
89+
90+
def as_dict(self):
91+
return prune_dict({
92+
"value_field": self.value_field,
93+
"label_field": self.label_field,
94+
"preview_url_field": self.preview_url_field,
95+
"preview_type": self.preview_type,
96+
"description_field": self.description_field,
97+
"search_fields": self.search_fields,
98+
})
99+
100+
46101
class RemoteOptions:
102+
"""Plain remote combo: fetches a list of strings/objects and populates a standard dropdown.
103+
104+
Use this for lightweight lists from endpoints that return a bare array (or an array under
105+
``response_key``). For rich dropdowns with previews, search, filtering, or pagination,
106+
use :class:`RemoteComboOptions` and the ``remote_combo=`` parameter on ``Combo.Input``.
107+
"""
47108
def __init__(self, route: str, refresh_button: bool, control_after_refresh: Literal["first", "last"]="first",
48109
timeout: int=None, max_retries: int=None, refresh: int=None):
49110
self.route = route
@@ -70,6 +131,113 @@ def as_dict(self):
70131
})
71132

72133

134+
class RemoteComboOptions:
135+
"""Rich remote combo: populates a Vue dropdown with previews, search, filtering, and pagination.
136+
137+
Attached to a :class:`Combo.Input` via ``remote_combo=`` (not ``remote=``). Requires an
138+
``item_schema`` describing how to map API response objects to dropdown items.
139+
140+
Response-shape contract:
141+
- Without ``page_size``: endpoint returns an array (or an array at ``response_key``).
142+
- With ``page_size``: endpoint returns ``{"items": [...], "has_more": bool}`` and is fetched
143+
progressively, appending each page to the dropdown.
144+
145+
Pagination contract (when ``page_size`` is set):
146+
- The frontend issues ``GET <route>?page=<n>&page_size=<size>`` with ``page`` starting at ``0``
147+
and incrementing by 1 until the endpoint returns ``has_more: false`` or an empty ``items`` list.
148+
- Endpoints that use 1-based pages, ``limit``/``offset``, or cursor/continuation tokens are not
149+
supported directly - adapt them via the proxy or
150+
expose a small shim endpoint that translates to the ``page`` + ``page_size`` + ``{items, has_more}`` shape.
151+
"""
152+
def __init__(
153+
self,
154+
route: str,
155+
item_schema: RemoteItemSchema,
156+
refresh_button: bool = True,
157+
auto_select: Literal["first", "last"] | None = None,
158+
timeout: int | None = None,
159+
max_retries: int | None = None,
160+
refresh: int | None = None,
161+
response_key: str | None = None,
162+
use_comfy_api: bool = False,
163+
page_size: int | None = None,
164+
):
165+
if page_size is not None:
166+
if response_key is not None:
167+
raise ValueError(
168+
"RemoteComboOptions: pass 'response_key' or 'page_size', not both. "
169+
"Paginated responses must use the top-level 'items' field."
170+
)
171+
if page_size < 1:
172+
raise ValueError(
173+
f"RemoteComboOptions: 'page_size' must be >= 1 when set (got {page_size})."
174+
)
175+
if auto_select is not None and auto_select not in ("first", "last"):
176+
raise ValueError(
177+
f"RemoteComboOptions: 'auto_select' must be 'first', 'last', or None; got {auto_select!r}."
178+
)
179+
if refresh is not None and 0 < refresh < 128:
180+
raise ValueError(
181+
f"RemoteComboOptions: 'refresh' must be >= 128 (ms TTL) or <= 0 (cache never expires); got {refresh}."
182+
)
183+
if timeout is not None and timeout < 0:
184+
raise ValueError(
185+
f"RemoteComboOptions: 'timeout' must be >= 0 (got {timeout})."
186+
)
187+
if max_retries is not None and max_retries < 0:
188+
raise ValueError(
189+
f"RemoteComboOptions: 'max_retries' must be >= 0 (got {max_retries})."
190+
)
191+
if not route.startswith("/"):
192+
parsed = urlparse(route)
193+
if not (parsed.scheme and parsed.netloc):
194+
raise ValueError(
195+
f"RemoteComboOptions: 'route' must start with '/' or be an absolute URL; got {route!r}."
196+
)
197+
if use_comfy_api:
198+
raise ValueError(
199+
f"RemoteComboOptions: 'use_comfy_api=True' cannot be combined with absolute URL {route!r}."
200+
)
201+
self.route = route
202+
"""The route to the remote source."""
203+
self.item_schema = item_schema
204+
"""Required: describes how each API response object maps to a dropdown item."""
205+
self.refresh_button = refresh_button
206+
"""Specifies whether to show a refresh button next to the widget."""
207+
self.auto_select = auto_select
208+
"""Fallback item to select when the widget's value is empty. Never overrides an existing
209+
selection. Default None means no fallback."""
210+
self.timeout = timeout
211+
"""Maximum time to wait for a response, in milliseconds."""
212+
self.max_retries = max_retries
213+
"""Maximum number of retries before aborting the request. Default None uses the frontend's built-in limit."""
214+
self.refresh = refresh
215+
"""TTL of the cached value in milliseconds. Must be >= 128 (ms TTL) or <= 0 (cache never expires,
216+
re-fetched only via the refresh button). Default None uses the frontend's built-in behavior."""
217+
self.response_key = response_key
218+
"""Dot-path to the items array in a non-paginated response. Mutually exclusive with
219+
``page_size``; paginated responses must use the top-level ``items`` field."""
220+
self.use_comfy_api = use_comfy_api
221+
"""When True, the frontend prepends the comfy-api base URL to ``route`` and injects auth headers."""
222+
self.page_size = page_size
223+
"""When set, switches the widget to progressive-fetch mode. The endpoint must return
224+
``{"items": [...], "has_more": bool}``."""
225+
226+
def as_dict(self):
227+
return prune_dict({
228+
"route": self.route,
229+
"item_schema": self.item_schema.as_dict(),
230+
"refresh_button": self.refresh_button,
231+
"auto_select": self.auto_select,
232+
"timeout": self.timeout,
233+
"max_retries": self.max_retries,
234+
"refresh": self.refresh,
235+
"response_key": self.response_key,
236+
"use_comfy_api": self.use_comfy_api,
237+
"page_size": self.page_size,
238+
})
239+
240+
73241
class NumberDisplay(str, Enum):
74242
number = "number"
75243
slider = "slider"
@@ -359,11 +527,16 @@ def __init__(
359527
upload: UploadType=None,
360528
image_folder: FolderType=None,
361529
remote: RemoteOptions=None,
530+
remote_combo: RemoteComboOptions=None,
362531
socketless: bool=None,
363532
extra_dict=None,
364533
raw_link: bool=None,
365534
advanced: bool=None,
366535
):
536+
if remote is not None and remote_combo is not None:
537+
raise ValueError("Combo.Input: pass either 'remote' or 'remote_combo', not both.")
538+
if options is not None and remote_combo is not None:
539+
raise ValueError("Combo.Input: pass either 'options' or 'remote_combo', not both.")
367540
if isinstance(options, type) and issubclass(options, Enum):
368541
options = [v.value for v in options]
369542
if isinstance(default, Enum):
@@ -375,6 +548,7 @@ def __init__(
375548
self.upload = upload
376549
self.image_folder = image_folder
377550
self.remote = remote
551+
self.remote_combo = remote_combo
378552
self.default: str
379553

380554
def as_dict(self):
@@ -385,6 +559,7 @@ def as_dict(self):
385559
**({self.upload.value: True} if self.upload is not None else {}),
386560
"image_folder": self.image_folder.value if self.image_folder else None,
387561
"remote": self.remote.as_dict() if self.remote else None,
562+
"remote_combo": self.remote_combo.as_dict() if self.remote_combo else None,
388563
})
389564

390565
class Output(Output):
@@ -2184,7 +2359,9 @@ def as_dict(self):
21842359
__all__ = [
21852360
"FolderType",
21862361
"UploadType",
2362+
"RemoteItemSchema",
21872363
"RemoteOptions",
2364+
"RemoteComboOptions",
21882365
"NumberDisplay",
21892366
"ControlAfterGenerate",
21902367

execution.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,10 @@ async def validate_inputs(prompt_id, prompt, item, validated):
991991

992992
if isinstance(input_type, list) or input_type == io.Combo.io_type:
993993
if input_type == io.Combo.io_type:
994+
# Skip validation for combos with remote options — options
995+
# are fetched client-side and not available on the server.
996+
if extra_info.get("remote_combo"):
997+
continue
994998
combo_options = extra_info.get("options", [])
995999
else:
9961000
combo_options = input_type

0 commit comments

Comments
 (0)