comfygit 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- comfygit-0.3.1.dist-info/METADATA +654 -0
- comfygit-0.3.1.dist-info/RECORD +30 -0
- comfygit-0.3.1.dist-info/WHEEL +4 -0
- comfygit-0.3.1.dist-info/entry_points.txt +3 -0
- comfygit-0.3.1.dist-info/licenses/LICENSE.txt +661 -0
- comfygit_cli/__init__.py +12 -0
- comfygit_cli/__main__.py +6 -0
- comfygit_cli/cli.py +704 -0
- comfygit_cli/cli_utils.py +32 -0
- comfygit_cli/completers.py +239 -0
- comfygit_cli/completion_commands.py +246 -0
- comfygit_cli/env_commands.py +2701 -0
- comfygit_cli/formatters/__init__.py +5 -0
- comfygit_cli/formatters/error_formatter.py +141 -0
- comfygit_cli/global_commands.py +1806 -0
- comfygit_cli/interactive/__init__.py +1 -0
- comfygit_cli/logging/compressed_handler.py +150 -0
- comfygit_cli/logging/environment_logger.py +554 -0
- comfygit_cli/logging/log_compressor.py +101 -0
- comfygit_cli/logging/logging_config.py +97 -0
- comfygit_cli/resolution_strategies.py +89 -0
- comfygit_cli/strategies/__init__.py +1 -0
- comfygit_cli/strategies/conflict_resolver.py +113 -0
- comfygit_cli/strategies/interactive.py +843 -0
- comfygit_cli/strategies/rollback.py +40 -0
- comfygit_cli/utils/__init__.py +12 -0
- comfygit_cli/utils/civitai_errors.py +9 -0
- comfygit_cli/utils/orchestrator.py +252 -0
- comfygit_cli/utils/pagination.py +82 -0
- comfygit_cli/utils/progress.py +128 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
"""Interactive resolution strategies for CLI."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from comfygit_cli.utils.civitai_errors import show_civitai_auth_help
|
|
5
|
+
from comfygit_cli.utils.progress import create_progress_callback, show_download_stats
|
|
6
|
+
from comfygit_core.models.protocols import (
|
|
7
|
+
ModelResolutionStrategy,
|
|
8
|
+
NodeResolutionStrategy,
|
|
9
|
+
)
|
|
10
|
+
from comfygit_core.models.shared import ModelWithLocation
|
|
11
|
+
from comfygit_core.models.workflow import (
|
|
12
|
+
ModelResolutionContext,
|
|
13
|
+
NodeResolutionContext,
|
|
14
|
+
ResolvedModel,
|
|
15
|
+
ResolvedNodePackage,
|
|
16
|
+
ScoredMatch,
|
|
17
|
+
WorkflowNodeWidgetRef,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InteractiveNodeStrategy(NodeResolutionStrategy):
|
|
22
|
+
"""Interactive node resolution with unified search."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize strategy (stateless - context passed per resolution)."""
|
|
26
|
+
self._last_choice: str | None = None # Track last user choice for optional detection
|
|
27
|
+
|
|
28
|
+
def _unified_choice_prompt(self, prompt_text: str, num_options: int, has_browse: bool = False) -> str:
|
|
29
|
+
"""Unified choice prompt with inline manual/skip/optional/refine options.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
prompt_text: The choice prompt like "Choice [1]/r/m/o/s: "
|
|
33
|
+
num_options: Number of valid numeric options (1-based)
|
|
34
|
+
has_browse: Whether option 0 (browse) is available
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
User's choice as string (number, 'm', 'o', 's', 'r', or '0' for browse)
|
|
38
|
+
"""
|
|
39
|
+
while True:
|
|
40
|
+
choice = input(prompt_text).strip().lower()
|
|
41
|
+
|
|
42
|
+
# Default to '1' if empty
|
|
43
|
+
if not choice:
|
|
44
|
+
return "1"
|
|
45
|
+
|
|
46
|
+
# Check special options
|
|
47
|
+
if choice in ('m', 's', 'o', 'r'):
|
|
48
|
+
return choice
|
|
49
|
+
|
|
50
|
+
# Check browse option
|
|
51
|
+
if has_browse and choice == '0':
|
|
52
|
+
return '0'
|
|
53
|
+
|
|
54
|
+
# Check numeric range
|
|
55
|
+
if choice.isdigit():
|
|
56
|
+
idx = int(choice)
|
|
57
|
+
if 1 <= idx <= num_options:
|
|
58
|
+
return choice
|
|
59
|
+
|
|
60
|
+
print(" Invalid choice, try again")
|
|
61
|
+
|
|
62
|
+
def _get_manual_package_id(self, node_type: str) -> ResolvedNodePackage | None:
|
|
63
|
+
"""Get package ID from manual user input."""
|
|
64
|
+
pkg_id = input("Enter package ID: ").strip()
|
|
65
|
+
if not pkg_id:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
print(f" Note: Package '{pkg_id}' will be verified during install")
|
|
69
|
+
return ResolvedNodePackage(
|
|
70
|
+
node_type=node_type,
|
|
71
|
+
match_type="manual",
|
|
72
|
+
package_id=pkg_id
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _get_optional_package(self, node_type: str) -> ResolvedNodePackage:
|
|
76
|
+
"""Get package ID from manual user input."""
|
|
77
|
+
return ResolvedNodePackage(
|
|
78
|
+
node_type=node_type,
|
|
79
|
+
match_type="optional"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def resolve_unknown_node(
|
|
83
|
+
self,
|
|
84
|
+
node_type: str,
|
|
85
|
+
possible: list[ResolvedNodePackage],
|
|
86
|
+
context: "NodeResolutionContext"
|
|
87
|
+
) -> ResolvedNodePackage | None:
|
|
88
|
+
"""Prompt user to resolve unknown node.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
node_type: The unknown node type
|
|
92
|
+
possible: List of possible package matches
|
|
93
|
+
context: Resolution context with search function and installed packages
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
ResolvedNodePackage or None to skip
|
|
97
|
+
"""
|
|
98
|
+
# Case 1: Ambiguous from global table (multiple matches)
|
|
99
|
+
if possible and len(possible) > 1:
|
|
100
|
+
return self._resolve_ambiguous(node_type, possible, context)
|
|
101
|
+
|
|
102
|
+
# Case 2: Single match from global table - confirm
|
|
103
|
+
# NOTE: Shouldn't be called since automatic resolution should handle this
|
|
104
|
+
if len(possible) == 1:
|
|
105
|
+
pkg = possible[0]
|
|
106
|
+
print(f"\n✓ Found in registry: {pkg.package_id}")
|
|
107
|
+
print(f" For node: {node_type}")
|
|
108
|
+
|
|
109
|
+
choice = input("Accept? [Y/n]: ").strip().lower()
|
|
110
|
+
if choice in ('', 'y', 'yes'):
|
|
111
|
+
return pkg
|
|
112
|
+
# User rejected - fall through to search
|
|
113
|
+
|
|
114
|
+
# Case 3: No matches or user rejected single match - use unified search with refinement
|
|
115
|
+
print(f"\n⚠️ Node not found in registry: {node_type}")
|
|
116
|
+
|
|
117
|
+
if not context.search_fn:
|
|
118
|
+
# No search available - can only mark optional, manual, or skip
|
|
119
|
+
print("\n [m] - Manually enter package ID")
|
|
120
|
+
print(" [o] - Mark as optional (workflow works without it)")
|
|
121
|
+
print(" [s] - Skip (leave unresolved)")
|
|
122
|
+
choice = self._unified_choice_prompt("Choice [m]/o/s: ", num_options=0, has_browse=False)
|
|
123
|
+
|
|
124
|
+
if choice == 'm':
|
|
125
|
+
self._last_choice = 'manual'
|
|
126
|
+
return self._get_manual_package_id(node_type)
|
|
127
|
+
elif choice == 'o':
|
|
128
|
+
self._last_choice = 'optional'
|
|
129
|
+
print(f" ✓ Marked '{node_type}' as optional")
|
|
130
|
+
return self._get_optional_package(node_type)
|
|
131
|
+
self._last_choice = 'skip'
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# Search with refinement loop
|
|
135
|
+
search_term = node_type
|
|
136
|
+
|
|
137
|
+
while True:
|
|
138
|
+
print(f"🔍 Searching for: {search_term}")
|
|
139
|
+
|
|
140
|
+
results = context.search_fn(
|
|
141
|
+
node_type=search_term,
|
|
142
|
+
installed_packages=context.installed_packages,
|
|
143
|
+
include_registry=True,
|
|
144
|
+
limit=5
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if results:
|
|
148
|
+
# Show results with refinement option
|
|
149
|
+
result = self._show_search_results_with_refinement(
|
|
150
|
+
node_type, search_term, results, context
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if result == "REFINE":
|
|
154
|
+
# User wants to refine search
|
|
155
|
+
new_term = input("\nEnter new search term: ").strip()
|
|
156
|
+
if new_term:
|
|
157
|
+
search_term = new_term
|
|
158
|
+
continue
|
|
159
|
+
else:
|
|
160
|
+
print(" Keeping previous search term")
|
|
161
|
+
continue
|
|
162
|
+
else:
|
|
163
|
+
# User made a choice (package, optional, manual, or skip)
|
|
164
|
+
# Type narrowing: result is ResolvedNodePackage | None
|
|
165
|
+
assert result is None or isinstance(result, ResolvedNodePackage)
|
|
166
|
+
return result
|
|
167
|
+
else:
|
|
168
|
+
# No results found
|
|
169
|
+
print(" No packages found")
|
|
170
|
+
print("\n [r] - Refine search")
|
|
171
|
+
print(" [m] - Manually enter package ID")
|
|
172
|
+
print(" [o] - Mark as optional (workflow works without it)")
|
|
173
|
+
print(" [s] - Skip (leave unresolved)")
|
|
174
|
+
choice = self._unified_choice_prompt("Choice [r]/m/o/s: ", num_options=0, has_browse=False)
|
|
175
|
+
|
|
176
|
+
if choice == 'r':
|
|
177
|
+
new_term = input("\nEnter new search term: ").strip()
|
|
178
|
+
if new_term:
|
|
179
|
+
search_term = new_term
|
|
180
|
+
continue
|
|
181
|
+
elif choice == 'm':
|
|
182
|
+
self._last_choice = 'manual'
|
|
183
|
+
return self._get_manual_package_id(node_type)
|
|
184
|
+
elif choice == 'o':
|
|
185
|
+
self._last_choice = 'optional'
|
|
186
|
+
print(f" ✓ Marked '{node_type}' as optional")
|
|
187
|
+
return self._get_optional_package(node_type)
|
|
188
|
+
self._last_choice = 'skip'
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def _resolve_ambiguous(
|
|
192
|
+
self,
|
|
193
|
+
node_type: str,
|
|
194
|
+
possible: list[ResolvedNodePackage],
|
|
195
|
+
context: "NodeResolutionContext"
|
|
196
|
+
) -> ResolvedNodePackage | None:
|
|
197
|
+
"""Handle ambiguous matches from global table."""
|
|
198
|
+
print(f"\n🔍 Found {len(possible)} matches for '{node_type}':")
|
|
199
|
+
display_count = min(5, len(possible))
|
|
200
|
+
for i, pkg in enumerate(possible[:display_count], 1):
|
|
201
|
+
display_name = pkg.package_data.display_name if pkg.package_data else pkg.package_id
|
|
202
|
+
desc = pkg.package_data.description if pkg.package_data else "No description"
|
|
203
|
+
print(f" {i}. {display_name or pkg.package_id}")
|
|
204
|
+
if desc and len(desc) > 60:
|
|
205
|
+
desc = desc[:57] + "..."
|
|
206
|
+
print(f" {desc}")
|
|
207
|
+
|
|
208
|
+
has_browse = len(possible) > 5
|
|
209
|
+
if has_browse:
|
|
210
|
+
print(f" 0. Browse all {len(possible)} matches")
|
|
211
|
+
|
|
212
|
+
print("\n [1-9] - Select package to install")
|
|
213
|
+
print(" [o] - Mark as optional (workflow works without it)")
|
|
214
|
+
print(" [m] - Manually enter package ID")
|
|
215
|
+
print(" [s] - Skip (leave unresolved)")
|
|
216
|
+
|
|
217
|
+
choice = self._unified_choice_prompt(
|
|
218
|
+
"Choice [1]/o/m/s: ",
|
|
219
|
+
num_options=display_count,
|
|
220
|
+
has_browse=has_browse
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if choice == 's':
|
|
224
|
+
self._last_choice = 'skip'
|
|
225
|
+
return None
|
|
226
|
+
elif choice == 'o':
|
|
227
|
+
# Return None to skip - caller will check _last_choice for optional
|
|
228
|
+
self._last_choice = 'optional'
|
|
229
|
+
print(f" ✓ Marked '{node_type}' as optional")
|
|
230
|
+
return self._get_optional_package(node_type)
|
|
231
|
+
elif choice == 'm':
|
|
232
|
+
self._last_choice = 'manual'
|
|
233
|
+
return self._get_manual_package_id(node_type)
|
|
234
|
+
elif choice == '0':
|
|
235
|
+
self._last_choice = 'browse'
|
|
236
|
+
selected = self._browse_all_packages(possible, context)
|
|
237
|
+
if selected == "BACK":
|
|
238
|
+
return None
|
|
239
|
+
elif isinstance(selected, ResolvedNodePackage):
|
|
240
|
+
return self._create_resolved_from_match(node_type, selected)
|
|
241
|
+
return None
|
|
242
|
+
else:
|
|
243
|
+
self._last_choice = 'select'
|
|
244
|
+
idx = int(choice) - 1
|
|
245
|
+
selected = possible[idx]
|
|
246
|
+
# Update match_type to ensure it's saved to node_mappings
|
|
247
|
+
# User selected from ambiguous list, so this counts as user confirmation
|
|
248
|
+
return self._create_resolved_from_match(node_type, selected)
|
|
249
|
+
|
|
250
|
+
def _show_search_results_with_refinement(
|
|
251
|
+
self,
|
|
252
|
+
node_type: str,
|
|
253
|
+
search_term: str,
|
|
254
|
+
results: list,
|
|
255
|
+
context: "NodeResolutionContext"
|
|
256
|
+
) -> ResolvedNodePackage | None | str:
|
|
257
|
+
"""Show search results with refinement option.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
ResolvedNodePackage - user selected a package
|
|
261
|
+
"REFINE" - user wants to refine search
|
|
262
|
+
None - user skipped or marked optional
|
|
263
|
+
"""
|
|
264
|
+
print(f"\nFound {len(results)} potential matches:\n")
|
|
265
|
+
|
|
266
|
+
display_count = min(5, len(results))
|
|
267
|
+
for i, match in enumerate(results[:display_count], 1):
|
|
268
|
+
pkg_id = match.package_id
|
|
269
|
+
desc = (match.package_data.description or "No description")[:60] if match.package_data else ""
|
|
270
|
+
installed_marker = " (installed)" if pkg_id in context.installed_packages else ""
|
|
271
|
+
|
|
272
|
+
print(f" {i}. {pkg_id}{installed_marker}")
|
|
273
|
+
if desc:
|
|
274
|
+
print(f" {desc}")
|
|
275
|
+
print()
|
|
276
|
+
|
|
277
|
+
print(" [1-5] - Select package to install")
|
|
278
|
+
print(" [r] - Refine search")
|
|
279
|
+
print(" [m] - Manually enter package ID")
|
|
280
|
+
print(" [o] - Mark as optional (workflow works without it)")
|
|
281
|
+
print(" [s] - Skip (leave unresolved)")
|
|
282
|
+
|
|
283
|
+
choice = self._unified_choice_prompt(
|
|
284
|
+
"Choice [1]/r/m/o/s: ",
|
|
285
|
+
num_options=display_count,
|
|
286
|
+
has_browse=False
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if choice == 's':
|
|
290
|
+
self._last_choice = 'skip'
|
|
291
|
+
return None
|
|
292
|
+
elif choice == 'r':
|
|
293
|
+
self._last_choice = 'refine'
|
|
294
|
+
return "REFINE"
|
|
295
|
+
elif choice == 'o':
|
|
296
|
+
self._last_choice = 'optional'
|
|
297
|
+
print(f" ✓ Marked '{node_type}' as optional")
|
|
298
|
+
return self._get_optional_package(node_type)
|
|
299
|
+
elif choice == 'm':
|
|
300
|
+
self._last_choice = 'manual'
|
|
301
|
+
return self._get_manual_package_id(node_type)
|
|
302
|
+
else:
|
|
303
|
+
self._last_choice = 'select'
|
|
304
|
+
idx = int(choice) - 1
|
|
305
|
+
selected = results[idx]
|
|
306
|
+
print(f"\n✓ Selected: {selected.package_id}")
|
|
307
|
+
return self._create_resolved_from_match(node_type, selected)
|
|
308
|
+
|
|
309
|
+
def _create_resolved_from_match(
|
|
310
|
+
self,
|
|
311
|
+
node_type: str,
|
|
312
|
+
match: ResolvedNodePackage
|
|
313
|
+
) -> ResolvedNodePackage:
|
|
314
|
+
"""Create ResolvedNodePackage from user-confirmed match.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
node_type: The node type being resolved
|
|
318
|
+
match: ResolvedNodePackage to update with user confirmation
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
ResolvedNodePackage with match_type="user_confirmed"
|
|
322
|
+
"""
|
|
323
|
+
# Use existing confidence or default to 1.0
|
|
324
|
+
confidence: float = getattr(match, 'match_confidence', None) or 1.0
|
|
325
|
+
versions = getattr(match, 'versions', [])
|
|
326
|
+
|
|
327
|
+
return ResolvedNodePackage(
|
|
328
|
+
package_id=match.package_id,
|
|
329
|
+
package_data=match.package_data,
|
|
330
|
+
node_type=node_type,
|
|
331
|
+
versions=versions,
|
|
332
|
+
match_type="user_confirmed",
|
|
333
|
+
match_confidence=confidence
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def _browse_all_packages(
|
|
337
|
+
self,
|
|
338
|
+
results: list[ResolvedNodePackage],
|
|
339
|
+
context: "NodeResolutionContext"
|
|
340
|
+
) -> ResolvedNodePackage | str | None:
|
|
341
|
+
"""Browse all matches with pagination.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
ResolvedNodePackage if user selects a package
|
|
345
|
+
"BACK" if user goes back
|
|
346
|
+
None if user quits
|
|
347
|
+
"""
|
|
348
|
+
page = 0
|
|
349
|
+
page_size = 10
|
|
350
|
+
total_pages = (len(results) + page_size - 1) // page_size
|
|
351
|
+
|
|
352
|
+
while True:
|
|
353
|
+
start = page * page_size
|
|
354
|
+
end = min(start + page_size, len(results))
|
|
355
|
+
|
|
356
|
+
print(f"\nAll matches (Page {page + 1}/{total_pages}):\n")
|
|
357
|
+
|
|
358
|
+
for i, match in enumerate(results[start:end], start + 1):
|
|
359
|
+
pkg_id = match.package_id
|
|
360
|
+
installed_marker = " (installed)" if pkg_id in context.installed_packages else ""
|
|
361
|
+
print(f" {i}. {pkg_id}{installed_marker}")
|
|
362
|
+
|
|
363
|
+
print("\n[N]ext, [P]rev, number, [B]ack, or [Q]uit:")
|
|
364
|
+
|
|
365
|
+
choice = input("Choice: ").strip().lower()
|
|
366
|
+
|
|
367
|
+
if choice == 'n':
|
|
368
|
+
if page < total_pages - 1:
|
|
369
|
+
page += 1
|
|
370
|
+
else:
|
|
371
|
+
print(" Already on last page")
|
|
372
|
+
elif choice == 'p':
|
|
373
|
+
if page > 0:
|
|
374
|
+
page -= 1
|
|
375
|
+
else:
|
|
376
|
+
print(" Already on first page")
|
|
377
|
+
elif choice == 'b':
|
|
378
|
+
return "BACK"
|
|
379
|
+
elif choice == 'q':
|
|
380
|
+
return None
|
|
381
|
+
elif choice.isdigit():
|
|
382
|
+
idx = int(choice) - 1
|
|
383
|
+
if 0 <= idx < len(results):
|
|
384
|
+
return results[idx]
|
|
385
|
+
else:
|
|
386
|
+
print(" Invalid number")
|
|
387
|
+
else:
|
|
388
|
+
print(" Invalid choice")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def confirm_node_install(self, package: ResolvedNodePackage) -> bool:
|
|
392
|
+
"""Always confirm since user already made the choice."""
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class InteractiveModelStrategy(ModelResolutionStrategy):
|
|
397
|
+
"""Interactive model resolution with user prompts."""
|
|
398
|
+
|
|
399
|
+
def _unified_choice_prompt(self, prompt_text: str, num_options: int, has_browse: bool = False) -> str:
|
|
400
|
+
"""Unified choice prompt with inline refine/skip/optional/download options.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
prompt_text: The choice prompt like "Choice [1]/r/o/s: "
|
|
404
|
+
num_options: Number of valid numeric options (1-based)
|
|
405
|
+
has_browse: Whether option 0 (browse) is available
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
User's choice as string (number, 'r', 'o', 's', 'd', or '0' for browse)
|
|
409
|
+
"""
|
|
410
|
+
while True:
|
|
411
|
+
choice = input(prompt_text).strip().lower()
|
|
412
|
+
|
|
413
|
+
# Default to '1' if empty
|
|
414
|
+
if not choice:
|
|
415
|
+
return "1"
|
|
416
|
+
|
|
417
|
+
# Check special options
|
|
418
|
+
if choice in ('r', 's', 'o', 'd'):
|
|
419
|
+
return choice
|
|
420
|
+
|
|
421
|
+
# Check browse option
|
|
422
|
+
if has_browse and choice == '0':
|
|
423
|
+
return '0'
|
|
424
|
+
|
|
425
|
+
# Check numeric range
|
|
426
|
+
if choice.isdigit():
|
|
427
|
+
idx = int(choice)
|
|
428
|
+
if 1 <= idx <= num_options:
|
|
429
|
+
return choice
|
|
430
|
+
|
|
431
|
+
print(" Invalid choice, try again")
|
|
432
|
+
|
|
433
|
+
def resolve_model(
|
|
434
|
+
self,
|
|
435
|
+
reference: WorkflowNodeWidgetRef,
|
|
436
|
+
candidates: list[ResolvedModel],
|
|
437
|
+
context: ModelResolutionContext
|
|
438
|
+
) -> ResolvedModel | None:
|
|
439
|
+
"""Unified model resolution - handles both ambiguous and missing models.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
reference: The model reference from workflow
|
|
443
|
+
candidates: List of potential matches (empty for missing models)
|
|
444
|
+
context: Resolution context with search function and workflow info
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
ResolvedModel with resolved_model set (or None for optional unresolved)
|
|
448
|
+
None to skip resolution
|
|
449
|
+
"""
|
|
450
|
+
# Case 1: Multiple candidates (ambiguous)
|
|
451
|
+
if len(candidates) > 1:
|
|
452
|
+
return self._handle_ambiguous(reference, candidates, context)
|
|
453
|
+
|
|
454
|
+
# Case 2: Single candidate (confirm)
|
|
455
|
+
if len(candidates) == 1:
|
|
456
|
+
return self._handle_single_candidate(reference, candidates[0], context)
|
|
457
|
+
|
|
458
|
+
# Case 3: No candidates (missing - use search)
|
|
459
|
+
return self._handle_missing(reference, context)
|
|
460
|
+
|
|
461
|
+
def _handle_ambiguous(
|
|
462
|
+
self,
|
|
463
|
+
reference: WorkflowNodeWidgetRef,
|
|
464
|
+
candidates: list[ResolvedModel],
|
|
465
|
+
context: ModelResolutionContext
|
|
466
|
+
) -> ResolvedModel | None:
|
|
467
|
+
"""Handle ambiguous models (multiple matches)."""
|
|
468
|
+
|
|
469
|
+
print(f"\n🔍 Multiple matches for model in node #{reference.node_id}:")
|
|
470
|
+
print(f" Looking for: {reference.widget_value}")
|
|
471
|
+
print(" Found matches:")
|
|
472
|
+
|
|
473
|
+
display_count = min(10, len(candidates))
|
|
474
|
+
for i, resolved in enumerate(candidates[:display_count], 1):
|
|
475
|
+
model = resolved.resolved_model
|
|
476
|
+
if model:
|
|
477
|
+
size_mb = model.file_size / (1024 * 1024)
|
|
478
|
+
print(f" {i}. {model.relative_path} ({size_mb:.1f} MB)")
|
|
479
|
+
|
|
480
|
+
print("\n [1-9] - Select model")
|
|
481
|
+
print(" [o] - Mark as optional (select from above)")
|
|
482
|
+
print(" [s] - Skip")
|
|
483
|
+
|
|
484
|
+
choice = self._unified_choice_prompt(
|
|
485
|
+
"Choice [1]/o/s: ",
|
|
486
|
+
num_options=display_count,
|
|
487
|
+
has_browse=False
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
if choice == 's':
|
|
491
|
+
return None
|
|
492
|
+
elif choice == 'o':
|
|
493
|
+
# User wants to mark as optional - prompt for which model
|
|
494
|
+
model_choice = input(" Which model? [1]: ").strip() or "1"
|
|
495
|
+
idx = int(model_choice) - 1
|
|
496
|
+
selected = candidates[idx]
|
|
497
|
+
# Return as optional
|
|
498
|
+
return ResolvedModel(
|
|
499
|
+
workflow=context.workflow_name,
|
|
500
|
+
reference=reference,
|
|
501
|
+
resolved_model=selected.resolved_model,
|
|
502
|
+
is_optional=True,
|
|
503
|
+
match_type="user_confirmed",
|
|
504
|
+
match_confidence=1.0
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
idx = int(choice) - 1
|
|
508
|
+
selected = candidates[idx]
|
|
509
|
+
if selected.resolved_model:
|
|
510
|
+
print(f" ✓ Selected: {selected.resolved_model.relative_path}")
|
|
511
|
+
return ResolvedModel(
|
|
512
|
+
workflow=context.workflow_name,
|
|
513
|
+
reference=reference,
|
|
514
|
+
resolved_model=selected.resolved_model,
|
|
515
|
+
is_optional=False,
|
|
516
|
+
match_type="user_confirmed",
|
|
517
|
+
match_confidence=1.0
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
def _handle_single_candidate(
|
|
521
|
+
self,
|
|
522
|
+
reference: WorkflowNodeWidgetRef,
|
|
523
|
+
candidate: ResolvedModel,
|
|
524
|
+
context: ModelResolutionContext
|
|
525
|
+
) -> ResolvedModel | None:
|
|
526
|
+
"""Handle single candidate (confirm with user)."""
|
|
527
|
+
model = candidate.resolved_model
|
|
528
|
+
if not model:
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
print(f"\n✓ Found match for: {reference.widget_value}")
|
|
532
|
+
print(f" {model.relative_path} ({model.file_size / (1024 * 1024):.1f} MB)")
|
|
533
|
+
|
|
534
|
+
choice = input("Accept? [Y/n/o]: ").strip().lower()
|
|
535
|
+
|
|
536
|
+
if choice in ('', 'y', 'yes'):
|
|
537
|
+
return ResolvedModel(
|
|
538
|
+
workflow=context.workflow_name,
|
|
539
|
+
reference=reference,
|
|
540
|
+
resolved_model=model,
|
|
541
|
+
is_optional=False,
|
|
542
|
+
match_type="user_confirmed",
|
|
543
|
+
match_confidence=1.0
|
|
544
|
+
)
|
|
545
|
+
elif choice == 'o':
|
|
546
|
+
return ResolvedModel(
|
|
547
|
+
workflow=context.workflow_name,
|
|
548
|
+
reference=reference,
|
|
549
|
+
resolved_model=model,
|
|
550
|
+
is_optional=True,
|
|
551
|
+
match_type="user_confirmed",
|
|
552
|
+
match_confidence=1.0
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
def _handle_missing(
|
|
558
|
+
self,
|
|
559
|
+
reference: WorkflowNodeWidgetRef,
|
|
560
|
+
context: ModelResolutionContext
|
|
561
|
+
) -> ResolvedModel | None:
|
|
562
|
+
"""Handle missing models with search and refinement loop."""
|
|
563
|
+
print(f"\n⚠️ Model not found: {reference.widget_value}")
|
|
564
|
+
print(f" in node #{reference.node_id} ({reference.node_type})")
|
|
565
|
+
|
|
566
|
+
if not context.search_fn:
|
|
567
|
+
# No search available - can only mark optional, download, or skip
|
|
568
|
+
options = "\n [o] - Mark as optional"
|
|
569
|
+
if context.downloader:
|
|
570
|
+
options += "\n [d] - Download from URL"
|
|
571
|
+
options += "\n [s] - Skip"
|
|
572
|
+
|
|
573
|
+
prompt = "Choice [o]/s: " if not context.downloader else "Choice [o]/d/s: "
|
|
574
|
+
print(options)
|
|
575
|
+
choice = self._unified_choice_prompt(prompt, num_options=0, has_browse=False)
|
|
576
|
+
|
|
577
|
+
if choice == 'o':
|
|
578
|
+
return ResolvedModel(
|
|
579
|
+
workflow=context.workflow_name,
|
|
580
|
+
reference=reference,
|
|
581
|
+
resolved_model=None,
|
|
582
|
+
is_optional=True,
|
|
583
|
+
match_type="optional_unresolved",
|
|
584
|
+
match_confidence=1.0
|
|
585
|
+
)
|
|
586
|
+
elif choice == 'd' and context.downloader:
|
|
587
|
+
return self._handle_download(reference, context)
|
|
588
|
+
return None
|
|
589
|
+
|
|
590
|
+
# Search with refinement loop
|
|
591
|
+
search_term = reference.widget_value
|
|
592
|
+
|
|
593
|
+
while True:
|
|
594
|
+
print(f"\n🔍 Searching for: {search_term}")
|
|
595
|
+
|
|
596
|
+
results = context.search_fn(
|
|
597
|
+
search_term=search_term,
|
|
598
|
+
node_type=reference.node_type,
|
|
599
|
+
limit=5
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if results:
|
|
603
|
+
# Show results with refinement option
|
|
604
|
+
result = self._show_search_results_with_refinement(
|
|
605
|
+
reference, results, context
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if result == "REFINE":
|
|
609
|
+
# User wants to refine search
|
|
610
|
+
new_term = input("\nEnter new search term: ").strip()
|
|
611
|
+
if new_term:
|
|
612
|
+
search_term = new_term
|
|
613
|
+
continue
|
|
614
|
+
else:
|
|
615
|
+
print(" Keeping previous search term")
|
|
616
|
+
continue
|
|
617
|
+
else:
|
|
618
|
+
# User made a choice (model, optional, or skip)
|
|
619
|
+
assert isinstance(result, ResolvedModel) or result is None
|
|
620
|
+
return result
|
|
621
|
+
else:
|
|
622
|
+
# No results found
|
|
623
|
+
print(" No models found")
|
|
624
|
+
print("\n [r] - Refine search")
|
|
625
|
+
if context.downloader:
|
|
626
|
+
print(" [d] - Download from URL")
|
|
627
|
+
print(" [o] - Mark as optional")
|
|
628
|
+
print(" [s] - Skip")
|
|
629
|
+
|
|
630
|
+
prompt = "Choice [r]/o/s: " if not context.downloader else "Choice [r]/d/o/s: "
|
|
631
|
+
choice = self._unified_choice_prompt(prompt, num_options=0, has_browse=False)
|
|
632
|
+
|
|
633
|
+
if choice == 'r':
|
|
634
|
+
new_term = input("\nEnter new search term: ").strip()
|
|
635
|
+
if new_term:
|
|
636
|
+
search_term = new_term
|
|
637
|
+
continue
|
|
638
|
+
elif choice == 'd' and context.downloader:
|
|
639
|
+
result = self._handle_download(reference, context)
|
|
640
|
+
if result is not None:
|
|
641
|
+
return result
|
|
642
|
+
# User pressed back - continue to show menu again
|
|
643
|
+
continue
|
|
644
|
+
elif choice == 'o':
|
|
645
|
+
return ResolvedModel(
|
|
646
|
+
workflow=context.workflow_name,
|
|
647
|
+
reference=reference,
|
|
648
|
+
resolved_model=None,
|
|
649
|
+
is_optional=True,
|
|
650
|
+
match_type="optional_unresolved",
|
|
651
|
+
match_confidence=1.0
|
|
652
|
+
)
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
def _show_search_results_with_refinement(
|
|
656
|
+
self,
|
|
657
|
+
reference: WorkflowNodeWidgetRef,
|
|
658
|
+
results: list[ScoredMatch],
|
|
659
|
+
context: ModelResolutionContext
|
|
660
|
+
) -> ResolvedModel | None | str:
|
|
661
|
+
"""Show search results with refinement option.
|
|
662
|
+
|
|
663
|
+
Shows up to 9 results max (per UX doc - no pagination).
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
ResolvedModel - user selected a model
|
|
667
|
+
"REFINE" - user wants to refine search
|
|
668
|
+
None - user skipped
|
|
669
|
+
"""
|
|
670
|
+
# Show up to 9 matches (UX doc spec - no pagination)
|
|
671
|
+
display_count = min(9, len(results))
|
|
672
|
+
print(f"\nFound {len(results)} matches:\n")
|
|
673
|
+
|
|
674
|
+
for i, match in enumerate(results[:display_count], 1):
|
|
675
|
+
model = match.model
|
|
676
|
+
size_gb = model.file_size / (1024 * 1024 * 1024)
|
|
677
|
+
confidence = match.confidence.capitalize()
|
|
678
|
+
print(f" {i}. {model.relative_path} ({size_gb:.2f} GB)")
|
|
679
|
+
print(f" {confidence} confidence match\n")
|
|
680
|
+
|
|
681
|
+
print(" [r] Refine search")
|
|
682
|
+
if context.downloader:
|
|
683
|
+
print(" [d] Download from URL")
|
|
684
|
+
print(" [o] Mark as optional")
|
|
685
|
+
print(" [s] Skip\n")
|
|
686
|
+
|
|
687
|
+
prompt = "Choice [1]/r/o/s: " if not context.downloader else "Choice [1]/r/d/o/s: "
|
|
688
|
+
choice = self._unified_choice_prompt(
|
|
689
|
+
prompt,
|
|
690
|
+
num_options=display_count,
|
|
691
|
+
has_browse=False
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if choice == 's':
|
|
695
|
+
return None
|
|
696
|
+
elif choice == 'r':
|
|
697
|
+
return "REFINE"
|
|
698
|
+
elif choice == 'd' and context.downloader:
|
|
699
|
+
result = self._handle_download(reference, context)
|
|
700
|
+
if result is not None:
|
|
701
|
+
return result
|
|
702
|
+
# User pressed back - return to search results
|
|
703
|
+
return "REFINE"
|
|
704
|
+
elif choice == 'o':
|
|
705
|
+
return ResolvedModel(
|
|
706
|
+
workflow=context.workflow_name,
|
|
707
|
+
reference=reference,
|
|
708
|
+
resolved_model=None,
|
|
709
|
+
is_optional=True,
|
|
710
|
+
match_type="optional_unresolved",
|
|
711
|
+
match_confidence=1.0
|
|
712
|
+
)
|
|
713
|
+
else:
|
|
714
|
+
idx = int(choice) - 1
|
|
715
|
+
selected = results[idx].model
|
|
716
|
+
print(f"\n✓ Selected: {selected.relative_path}")
|
|
717
|
+
return ResolvedModel(
|
|
718
|
+
workflow=context.workflow_name,
|
|
719
|
+
reference=reference,
|
|
720
|
+
resolved_model=selected,
|
|
721
|
+
is_optional=False,
|
|
722
|
+
match_type="user_confirmed",
|
|
723
|
+
match_confidence=1.0
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _handle_download(
|
|
728
|
+
self,
|
|
729
|
+
reference: WorkflowNodeWidgetRef,
|
|
730
|
+
context: ModelResolutionContext
|
|
731
|
+
) -> ResolvedModel | None:
|
|
732
|
+
"""Handle download intent collection with path confirmation.
|
|
733
|
+
|
|
734
|
+
Returns download intent instead of immediately downloading the model.
|
|
735
|
+
Actual downloads happen in batch at the end of resolution.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
reference: Model reference from workflow
|
|
739
|
+
context: Resolution context
|
|
740
|
+
|
|
741
|
+
Returns:
|
|
742
|
+
ResolvedModel with download_intent if user provides URL, None to skip
|
|
743
|
+
"""
|
|
744
|
+
from pathlib import Path
|
|
745
|
+
|
|
746
|
+
if not context.downloader:
|
|
747
|
+
print(" Download not available")
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
# Step 1: Get URL
|
|
751
|
+
url = input("\nEnter download URL: ").strip()
|
|
752
|
+
if not url:
|
|
753
|
+
print(" Cancelled")
|
|
754
|
+
return None
|
|
755
|
+
|
|
756
|
+
# Step 2: Suggest path
|
|
757
|
+
suggested_path = context.downloader.suggest_path(
|
|
758
|
+
url=url,
|
|
759
|
+
node_type=reference.node_type,
|
|
760
|
+
filename_hint=reference.widget_value
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
# Step 3: Path confirmation loop
|
|
764
|
+
while True:
|
|
765
|
+
print("\nModel will be downloaded to:")
|
|
766
|
+
print(f" {suggested_path}")
|
|
767
|
+
print("\n[Y] Continue [m] Change path [b] Back to menu")
|
|
768
|
+
|
|
769
|
+
choice = input("Choice [Y]/m/b: ").strip().lower()
|
|
770
|
+
|
|
771
|
+
if choice == 'b':
|
|
772
|
+
return None # Back to menu
|
|
773
|
+
elif choice == 'm':
|
|
774
|
+
new_path = input("Enter path: ").strip()
|
|
775
|
+
if new_path:
|
|
776
|
+
suggested_path = Path(new_path)
|
|
777
|
+
continue
|
|
778
|
+
elif choice in ('', 'y'):
|
|
779
|
+
break
|
|
780
|
+
|
|
781
|
+
# Step 4: Return download intent (actual download happens in batch at end)
|
|
782
|
+
print(f" ✓ Download queued: {suggested_path}")
|
|
783
|
+
return ResolvedModel(
|
|
784
|
+
workflow=context.workflow_name,
|
|
785
|
+
reference=reference,
|
|
786
|
+
resolved_model=None, # Not downloaded yet
|
|
787
|
+
model_source=url,
|
|
788
|
+
is_optional=False,
|
|
789
|
+
match_type="download_intent",
|
|
790
|
+
target_path=suggested_path
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def _browse_all_models(self, results: list[ScoredMatch]) -> ModelWithLocation | str | None:
|
|
794
|
+
"""Browse all fuzzy search results with pagination.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
ModelWithLocation if user selects a model
|
|
798
|
+
"BACK" if user cancels
|
|
799
|
+
None if user quits
|
|
800
|
+
"""
|
|
801
|
+
page = 0
|
|
802
|
+
page_size = 10
|
|
803
|
+
total_pages = (len(results) + page_size - 1) // page_size
|
|
804
|
+
|
|
805
|
+
while True:
|
|
806
|
+
start = page * page_size
|
|
807
|
+
end = min(start + page_size, len(results))
|
|
808
|
+
|
|
809
|
+
print(f"\nAll matches (Page {page + 1}/{total_pages}):\n")
|
|
810
|
+
|
|
811
|
+
for i, match in enumerate(results[start:end], start + 1):
|
|
812
|
+
model = match.model
|
|
813
|
+
size_gb = model.file_size / (1024 * 1024 * 1024)
|
|
814
|
+
print(f" {i}. {model.relative_path} ({size_gb:.2f} GB)")
|
|
815
|
+
|
|
816
|
+
print("\n[N]ext, [P]rev, number, [B]ack, or [Q]uit:")
|
|
817
|
+
|
|
818
|
+
choice = input("Choice: ").strip().lower()
|
|
819
|
+
|
|
820
|
+
if choice == 'n':
|
|
821
|
+
if page < total_pages - 1:
|
|
822
|
+
page += 1
|
|
823
|
+
else:
|
|
824
|
+
print(" Already on last page")
|
|
825
|
+
elif choice == 'p':
|
|
826
|
+
if page > 0:
|
|
827
|
+
page -= 1
|
|
828
|
+
else:
|
|
829
|
+
print(" Already on first page")
|
|
830
|
+
elif choice == 'b':
|
|
831
|
+
return "BACK"
|
|
832
|
+
elif choice == 'q':
|
|
833
|
+
return None
|
|
834
|
+
elif choice.isdigit():
|
|
835
|
+
idx = int(choice) - 1
|
|
836
|
+
if 0 <= idx < len(results):
|
|
837
|
+
return results[idx].model
|
|
838
|
+
else:
|
|
839
|
+
print(" Invalid number")
|
|
840
|
+
else:
|
|
841
|
+
print(" Invalid choice")
|
|
842
|
+
|
|
843
|
+
|