99from enum import Enum
1010from typing import Any , Callable , Literal , TypedDict , TypeVar , TYPE_CHECKING
1111from typing_extensions import NotRequired , final
12+ from urllib .parse import urlparse
1213
1314# used for type hinting
1415import 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+
46101class 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+
73241class 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
0 commit comments