From cdd77029b691e39bf117b6bed0a1734f7e9a7200 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Wed, 15 Apr 2026 22:18:25 +0800 Subject: [PATCH] fix(autocomplete): improve wildcard onboarding UX --- README.md | 35 ++ py/routes/handlers/misc_handlers.py | 24 +- py/routes/misc_route_registrar.py | 1 + py/services/wildcard_service.py | 16 + .../components/autocomplete.behavior.test.js | 133 ++++++- tests/routes/test_misc_routes.py | 32 ++ tests/routes/test_wildcard_routes.py | 26 +- tests/services/test_wildcard_service.py | 11 + web/comfyui/autocomplete.js | 357 +++++++++++++----- web/comfyui/autocomplete_wildcards.js | 35 ++ 10 files changed, 573 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 73967425..c6554b86 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,41 @@ pip install -r requirements.txt - Paste into the Lora Loader node's text input - The node will automatically apply preset strength and trigger words +### Wildcards for TextLM / PromptLM + +`Text (LoraManager)` and `Prompt (LoraManager)` support `/wildcard` autocomplete plus runtime wildcard expansion. + +- Wildcard files live in `{settings folder}/wildcards/` +- When you type `/wildcard` and no wildcard files exist yet, the autocomplete dropdown shows the exact folder path and lets you open it +- Supported formats: `.txt`, `.yaml`, `.yml`, `.json` + +Format rules: + +- `wildcards/animals/cat.txt` becomes `__animals/cat__` +- `.txt` files use one option per line +- YAML / JSON files use nested keys that end in string arrays + +Examples: + +```txt +# wildcards/color.txt +red +blue +green +``` + +Use it as `__color__`. + +```yaml +# wildcards/colors.yaml +palette: + warm: + - red + - orange +``` + +Use it as `__palette/warm__`. + ### Filename Format Patterns for Save Image Node The Save Image Node supports dynamic filename generation using pattern codes. You can customize how your images are named using the following format patterns: diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index cb1a427d..335e21ee 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -2411,6 +2411,16 @@ class FileSystemHandler: logger.error("Failed to open backup location: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def open_wildcards_location(self, request: web.Request) -> web.Response: + try: + from ...services.wildcard_service import get_wildcards_dir + + wildcards_dir = get_wildcards_dir(create=True) + return await self._open_path(wildcards_dir) + except Exception as exc: # pragma: no cover - defensive logging + logger.error("Failed to open wildcards location: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + class CustomWordsHandler: """Handler for autocomplete via TagFTSIndex.""" @@ -2507,8 +2517,19 @@ class WildcardsHandler: search_term = request.query.get("search", "") limit = min(int(request.query.get("limit", "20")), 100) offset = max(0, int(request.query.get("offset", "0"))) + metadata = self._service.get_metadata(create_dir=True) results = self._service.search_keys(search_term, limit=limit, offset=offset) - return web.json_response({"success": True, "words": results}) + return web.json_response( + { + "success": True, + "words": results, + "meta": { + "has_wildcards": metadata.has_wildcards, + "wildcards_dir": metadata.wildcards_dir, + "supported_formats": list(metadata.supported_formats), + }, + } + ) except Exception as exc: logger.error("Error searching wildcards: %s", exc, exc_info=True) return web.json_response({"error": str(exc)}, status=500) @@ -2801,6 +2822,7 @@ class MiscHandlerSet: "open_file_location": self.filesystem.open_file_location, "open_settings_location": self.filesystem.open_settings_location, "open_backup_location": self.filesystem.open_backup_location, + "open_wildcards_location": self.filesystem.open_wildcards_location, "search_custom_words": self.custom_words.search_custom_words, "search_wildcards": self.wildcards.search_wildcards, "get_supporters": self.supporters.get_supporters, diff --git a/py/routes/misc_route_registrar.py b/py/routes/misc_route_registrar.py index 0be5e9fc..d8c05ea0 100644 --- a/py/routes/misc_route_registrar.py +++ b/py/routes/misc_route_registrar.py @@ -31,6 +31,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/health-check", "health_check"), RouteDefinition("GET", "/api/lm/supporters", "get_supporters"), RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"), + RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"), RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"), RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"), RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"), diff --git a/py/services/wildcard_service.py b/py/services/wildcard_service.py index 248ec23c..b01eef15 100644 --- a/py/services/wildcard_service.py +++ b/py/services/wildcard_service.py @@ -55,6 +55,13 @@ class WildcardEntry: values_count: int +@dataclass(frozen=True) +class WildcardMetadata: + has_wildcards: bool + wildcards_dir: str + supported_formats: tuple[str, ...] + + class WildcardService: """Discover wildcard keys and expand wildcard syntax.""" @@ -134,6 +141,14 @@ class WildcardService: for key, values in sorted(self.get_wildcard_dict().items()) ] + def get_metadata(self, *, create_dir: bool = False) -> WildcardMetadata: + wildcards_dir = get_wildcards_dir(create=create_dir) + return WildcardMetadata( + has_wildcards=bool(self.get_wildcard_dict()), + wildcards_dir=wildcards_dir, + supported_formats=(".txt", ".yaml", ".yml", ".json"), + ) + def _build_signature(self) -> tuple[tuple[str, int, int], ...]: root = get_wildcards_dir(create=False) if not os.path.isdir(root): @@ -405,6 +420,7 @@ def get_wildcard_service() -> WildcardService: __all__ = [ "WildcardService", + "WildcardMetadata", "contains_dynamic_syntax", "get_wildcard_service", "get_wildcards_dir", diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index 3af066a0..7c5a7b53 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -1209,7 +1209,15 @@ describe('AutoComplete widget interactions', () => { vi.useFakeTimers(); fetchApiMock.mockResolvedValue({ - json: () => Promise.resolve({ success: true, words: ['animals/cat'] }), + json: () => Promise.resolve({ + success: true, + words: ['animals/cat'], + meta: { + has_wildcards: true, + wildcards_dir: '/tmp/settings/wildcards', + supported_formats: ['.txt', '.yaml', '.yml', '.json'], + }, + }), }); caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat'); @@ -1237,6 +1245,129 @@ describe('AutoComplete widget interactions', () => { expect(autoComplete.items).toEqual(['animals/cat']); }); + it('shows wildcard onboarding when /wildcard is used before any files exist', async () => { + vi.useFakeTimers(); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ + success: true, + words: [], + meta: { + has_wildcards: false, + wildcards_dir: '/tmp/settings/wildcards', + supported_formats: ['.txt', '.yaml', '.yml', '.json'], + }, + }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '/wildcard cat'; + input.selectionStart = input.value.length; + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input,'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(autoComplete.isVisible).toBe(true); + expect(autoComplete.items).toHaveLength(1); + expect(autoComplete.items[0].type).toBe('wildcard_empty_state'); + expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet'); + expect(autoComplete.dropdown.textContent).toContain('/tmp/settings/wildcards'); + expect(autoComplete.dropdown.textContent).toContain('.txt, .yaml, .yml, .json'); + }); + + it('shows wildcard onboarding when only the /wildcard command is entered', async () => { + vi.useFakeTimers(); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ + success: true, + words: [], + meta: { + has_wildcards: false, + wildcards_dir: '/tmp/settings/wildcards', + supported_formats: ['.txt', '.yaml', '.yml', '.json'], + }, + }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard '); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '/wildcard '; + input.selectionStart = input.value.length; + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input,'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=&limit=100'); + expect(autoComplete.isVisible).toBe(true); + expect(autoComplete.items).toHaveLength(1); + expect(autoComplete.items[0].type).toBe('wildcard_empty_state'); + expect(autoComplete.dropdown.textContent).toContain('No wildcards found yet'); + }); + + it('shows a lightweight no-match state when wildcard files exist but search misses', async () => { + vi.useFakeTimers(); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ + success: true, + words: [], + meta: { + has_wildcards: true, + wildcards_dir: '/tmp/settings/wildcards', + supported_formats: ['.txt', '.yaml', '.yml', '.json'], + }, + }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard dragon'); + caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); + + const input = document.createElement('textarea'); + input.value = '/wildcard dragon'; + input.selectionStart = input.value.length; + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input,'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(autoComplete.items).toHaveLength(1); + expect(autoComplete.items[0].type).toBe('wildcard_no_matches'); + expect(autoComplete.dropdown.textContent).toContain('No wildcard matches'); + expect(autoComplete.dropdown.textContent).not.toContain('Open wildcards folder'); + }); + it('inserts wildcard references when accepting a /wildcard result', async () => { caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat'); diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index da6d64bd..26518622 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -499,6 +499,38 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc assert calls == [["xdg-open", str(backup_dir)]] +@pytest.mark.asyncio +async def test_open_wildcards_location_creates_and_opens_directory(tmp_path, monkeypatch): + wildcards_dir = tmp_path / "settings" / "wildcards" + + handler = FileSystemHandler(settings_service=SimpleNamespace(settings_file=str(tmp_path / "settings.json"))) + + calls = [] + + def fake_popen(args): + calls.append(args) + return MagicMock() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_docker", lambda: False) + monkeypatch.setattr("py.routes.handlers.misc_handlers._is_wsl", lambda: False) + monkeypatch.setattr( + "py.services.wildcard_service.get_wildcards_dir", + lambda create=False: str(wildcards_dir.mkdir(parents=True, exist_ok=True) or wildcards_dir) + if create + else str(wildcards_dir), + ) + + response = await handler.open_wildcards_location(FakeRequest()) + payload = json.loads(response.text) + + assert response.status == 200 + assert payload["success"] is True + assert payload["path"] == str(wildcards_dir) + assert wildcards_dir.is_dir() + assert calls == [["xdg-open", str(wildcards_dir)]] + + class RecordingRouter: def __init__(self): self.calls = [] diff --git a/tests/routes/test_wildcard_routes.py b/tests/routes/test_wildcard_routes.py index ca013c4b..7d86298e 100644 --- a/tests/routes/test_wildcard_routes.py +++ b/tests/routes/test_wildcard_routes.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from types import SimpleNamespace import pytest @@ -15,6 +16,14 @@ class FakeRequest: @pytest.mark.asyncio async def test_search_wildcards_returns_results(): class StubService: + def get_metadata(self, create_dir=False): + assert create_dir is True + return SimpleNamespace( + has_wildcards=True, + wildcards_dir="/tmp/settings/wildcards", + supported_formats=(".txt", ".yaml", ".yml", ".json"), + ) + def search_keys(self, search_term, limit, offset): assert search_term == "cat" assert limit == 25 @@ -28,12 +37,27 @@ async def test_search_wildcards_returns_results(): payload = json.loads(response.text) assert response.status == 200 - assert payload == {"success": True, "words": ["animals/cat"]} + assert payload == { + "success": True, + "words": ["animals/cat"], + "meta": { + "has_wildcards": True, + "wildcards_dir": "/tmp/settings/wildcards", + "supported_formats": [".txt", ".yaml", ".yml", ".json"], + }, + } @pytest.mark.asyncio async def test_search_wildcards_handles_errors(): class StubService: + def get_metadata(self, create_dir=False): + return SimpleNamespace( + has_wildcards=False, + wildcards_dir="/tmp/settings/wildcards", + supported_formats=(".txt",), + ) + def search_keys(self, search_term, limit, offset): raise RuntimeError("boom") diff --git a/tests/services/test_wildcard_service.py b/tests/services/test_wildcard_service.py index dc298a59..a5fc7f9f 100644 --- a/tests/services/test_wildcard_service.py +++ b/tests/services/test_wildcard_service.py @@ -68,6 +68,17 @@ def test_search_keys_supports_offset_and_limit(monkeypatch, tmp_path): assert service.search_keys("cat", limit=1, offset=1) == ["catgirl"] +def test_get_metadata_creates_directory_and_reports_formats(monkeypatch, tmp_path): + service, wildcards_dir = _make_service(monkeypatch, tmp_path) + + metadata = service.get_metadata(create_dir=True) + + assert metadata.has_wildcards is False + assert metadata.wildcards_dir == str(wildcards_dir) + assert metadata.supported_formats == (".txt", ".yaml", ".yml", ".json") + assert wildcards_dir.is_dir() + + def test_expand_text_resolves_nested_wildcards(monkeypatch, tmp_path): service, wildcards_dir = _make_service(monkeypatch, tmp_path) wildcards_dir.mkdir() diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 1c3006c0..c43499dc 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -3,9 +3,12 @@ import { app } from "../../scripts/app.js"; import { TextAreaCaretHelper } from "./textarea_caret_helper.js"; import { WILDCARD_COMMANDS, + createWildcardEmptyStateItem, + createWildcardNoMatchesItem, getWildcardInsertText, getWildcardSearchEndpoint, isWildcardCommand, + isWildcardInfoItem, } from "./autocomplete_wildcards.js"; import { getAutocompleteAppendCommaPreference, @@ -363,6 +366,7 @@ class AutoComplete { this.debounceTimer = null; this.isVisible = false; this.currentSearchTerm = ''; + this.wildcardMeta = null; this.previewTooltip = null; this.previewTooltipPromise = null; this.searchType = null; @@ -703,7 +707,12 @@ class AutoComplete { } } - if (searchTerm.length < this.options.minChars) { + const allowEmptyWildcardSearch = + this.modelType === 'prompt' && + this.searchType === 'wildcards' && + searchTerm.length === 0; + + if (!allowEmptyWildcardSearch && searchTerm.length < this.options.minChars) { this.hide(); return; } @@ -1005,12 +1014,24 @@ class AutoComplete { return uniqueQueries; } + _containsInformationalItems() { + return this.items.some((item) => isWildcardInfoItem(item)); + } + + _isSelectableInfoItem(item) { + return isWildcardInfoItem(item); + } + /** * Get display text for an item (without extension for models) * @param {string|Object} item - Item to get display text from * @returns {string} - Display text without extension */ _getDisplayText(item) { + if (isWildcardInfoItem(item)) { + return item.title || item.description || 'Wildcards'; + } + const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item); // Remove extension for models to avoid matching/displaying .safetensors etc. if (this.modelType === 'loras' || this.searchType === 'embeddings') { @@ -1159,6 +1180,7 @@ class AutoComplete { // This is critical for preventing command suggestions from persisting // when switching from command mode to regular tag search this.items = []; + this.wildcardMeta = null; if (!endpoint) { endpoint = `/lm/${this.modelType}/relative-paths`; @@ -1167,7 +1189,10 @@ class AutoComplete { // Generate multiple query variations for better matching, but avoid // sending duplicate-equivalent requests that normalize to the same // backend search term. - const queriesToExecute = this._getQueriesToExecute(term); + const queriesToExecute = + this.searchType === 'wildcards' && term.length === 0 + ? [''] + : this._getQueriesToExecute(term); if (queriesToExecute.length === 0) { this.items = []; @@ -1184,10 +1209,16 @@ class AutoComplete { try { const response = await api.fetchApi(url); const data = await response.json(); - return data.success ? (data.relative_paths || data.words || []) : []; + return { + items: data.success ? (data.relative_paths || data.words || []) : [], + meta: data?.meta || null, + }; } catch (error) { console.warn(`Search query failed for "${query}":`, error); - return []; + return { + items: [], + meta: null, + }; } }); @@ -1205,7 +1236,12 @@ class AutoComplete { const seen = new Set(); const mergedItems = []; - for (const resultArray of resultsArrays) { + for (const result of resultsArrays) { + if (!this.wildcardMeta && result?.meta) { + this.wildcardMeta = result.meta; + } + + const resultArray = result?.items || []; for (const item of resultArray) { const itemKey = typeof item === 'object' && item.tag_name ? item.tag_name.toLowerCase() @@ -1218,6 +1254,17 @@ class AutoComplete { } } + if (this.searchType === 'wildcards' && mergedItems.length === 0) { + const meta = this.wildcardMeta || {}; + this.items = meta.has_wildcards + ? [createWildcardNoMatchesItem(term, meta)] + : [createWildcardEmptyStateItem(meta)]; + this.hasMoreItems = false; + this.render(); + this.show(); + return; + } + // Use backend-sorted results directly without re-scoring // Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost if (mergedItems.length > 0) { @@ -1486,91 +1533,7 @@ class AutoComplete { const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0]; this.items.forEach((itemData, index) => { - const item = document.createElement('div'); - item.className = 'comfy-autocomplete-item'; - - if (isCommand) { - // Render command item - const cmdSpan = document.createElement('span'); - cmdSpan.className = 'lm-autocomplete-command-name'; - cmdSpan.textContent = itemData.command; - - const labelSpan = document.createElement('span'); - labelSpan.className = 'lm-autocomplete-command-label'; - labelSpan.textContent = itemData.label; - - item.appendChild(cmdSpan); - item.appendChild(labelSpan); - item.style.cssText = ` - padding: 8px 12px; - cursor: pointer; - color: rgba(226, 232, 240, 0.8); - border-bottom: 1px solid rgba(226, 232, 240, 0.1); - transition: all 0.2s ease; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - `; - } else if (isEnriched) { - // Render enriched item with category badge and post count - this._renderEnrichedItem(item, itemData, this.currentSearchTerm); - } else { - // Create highlighted content for simple items, wrapped in a span - // to prevent flex layout from breaking up the text - const nameSpan = document.createElement('span'); - nameSpan.className = 'lm-autocomplete-name'; - // Use display text without extension for cleaner UI - const displayTextWithoutExt = this._getDisplayText(itemData); - nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm); - nameSpan.style.cssText = ` - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - `; - item.appendChild(nameSpan); - - // Apply item styles with new color scheme - item.style.cssText = ` - padding: 8px 12px; - cursor: pointer; - color: rgba(226, 232, 240, 0.8); - border-bottom: 1px solid rgba(226, 232, 240, 0.1); - transition: all 0.2s ease; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - `; - } - - // Hover and selection handlers - item.addEventListener('mouseenter', () => { - this.selectItem(index, { manual: true }); - }); - - item.addEventListener('mouseleave', () => { - this.hidePreview(); - }); - - // Click handler - item.addEventListener('click', () => { - if (isCommand) { - this._insertCommand(itemData.command); - } else { - const insertPath = isEnriched ? itemData.tag_name : itemData; - this.insertSelection(insertPath); - } - }); - + const item = this.createItemElement(itemData, index, isEnriched, isCommand); this.dropdown.appendChild(item); }); @@ -1664,6 +1627,124 @@ class AutoComplete { itemEl.appendChild(nameSpan); itemEl.appendChild(metaSpan); } + + _renderInformationalItem(itemEl, itemData) { + itemEl.classList.add('comfy-autocomplete-info-item'); + itemEl.style.cssText = ` + padding: 12px; + color: rgba(226, 232, 240, 0.88); + border-bottom: none; + cursor: default; + display: block; + white-space: normal; + height: auto; + `; + + const title = document.createElement('div'); + title.className = 'lm-autocomplete-info-title'; + title.textContent = itemData.title || 'Wildcards'; + title.style.cssText = ` + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + `; + itemEl.appendChild(title); + + const description = document.createElement('div'); + description.className = 'lm-autocomplete-info-description'; + description.textContent = itemData.description || ''; + description.style.cssText = ` + font-size: 12px; + line-height: 1.45; + color: rgba(226, 232, 240, 0.72); + `; + itemEl.appendChild(description); + + if (itemData.type === 'wildcard_no_matches') { + return; + } + + const pathBlock = document.createElement('div'); + pathBlock.style.cssText = ` + margin-top: 10px; + padding: 8px 10px; + border-radius: 6px; + background: rgba(15, 23, 42, 0.6); + font-size: 11px; + line-height: 1.5; + `; + pathBlock.innerHTML = [ + '
Wildcards folder
', + `${itemData.wildcardsDir || '(unavailable)'}`, + `
Supported formats: ${(itemData.supportedFormats || []).join(', ')}
`, + ].join(''); + itemEl.appendChild(pathBlock); + + const examples = document.createElement('div'); + examples.style.cssText = ` + margin-top: 10px; + font-size: 11px; + line-height: 1.55; + color: rgba(226, 232, 240, 0.72); + `; + examples.innerHTML = [ + '
Examples
', + '
animals/cat.txt -> use __animals/cat__
', + '
colors.yaml with palette: { warm: [red, orange] } -> use __palette/warm__
', + '
Text files use one option per line. YAML/JSON use nested keys ending in string arrays.
', + ].join(''); + itemEl.appendChild(examples); + + const actions = document.createElement('div'); + actions.style.cssText = ` + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; + `; + + const openButton = document.createElement('button'); + openButton.type = 'button'; + openButton.dataset.action = 'open-wildcards-folder'; + openButton.textContent = 'Open wildcards folder'; + openButton.style.cssText = ` + border: 1px solid rgba(96, 165, 250, 0.45); + background: rgba(37, 99, 235, 0.18); + color: #dbeafe; + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + cursor: pointer; + `; + openButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await this._openWildcardsFolder(); + }); + actions.appendChild(openButton); + + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.dataset.action = 'copy-wildcards-path'; + copyButton.textContent = 'Copy path'; + copyButton.style.cssText = ` + border: 1px solid rgba(226, 232, 240, 0.2); + background: rgba(148, 163, 184, 0.12); + color: rgba(226, 232, 240, 0.88); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + cursor: pointer; + `; + copyButton.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + await this._copyWildcardPath(itemData.wildcardsDir || ''); + }); + actions.appendChild(copyButton); + + itemEl.appendChild(actions); + } highlightMatch(text, searchTerm) { const { include } = parseSearchTokens(searchTerm); @@ -1681,6 +1762,62 @@ class AutoComplete { '$1', ); } + + async _openWildcardsFolder() { + try { + const response = await api.fetchApi('/lm/wildcards/open-location', { method: 'POST' }); + const data = await response.json(); + if (!response.ok || data?.success === false) { + throw new Error(data?.error || 'Failed to open wildcards folder'); + } + + if (data?.mode === 'clipboard' && data?.path) { + await this._copyWildcardPath(data.path); + return; + } + + showToast({ + severity: 'success', + summary: 'Wildcards folder', + detail: 'Opened wildcards folder.', + life: 2500, + }); + } catch (error) { + console.error('[Lora Manager] Failed to open wildcards folder:', error); + showToast({ + severity: 'error', + summary: 'Error', + detail: error?.message || 'Failed to open wildcards folder', + life: 3000, + }); + } + } + + async _copyWildcardPath(path) { + if (!path) { + return; + } + + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(path); + } + showToast({ + severity: 'info', + summary: 'Wildcards path', + detail: path, + life: 3000, + }); + } catch (error) { + console.error('[Lora Manager] Failed to copy wildcards path:', error); + showToast({ + severity: 'warn', + summary: 'Wildcards path', + detail: path, + life: 4000, + }); + } + } showPreviewForItem(relativePath, itemElement) { if (!this.options.showPreview || !this.previewTooltip) return; @@ -1874,6 +2011,14 @@ class AutoComplete { updateVirtualScrollHeight() { if (!this.contentContainer || !this.scrollContainer) return; + if (this._containsInformationalItems()) { + this.totalHeight = 0; + this.contentContainer.style.height = 'auto'; + this.scrollContainer.style.maxHeight = `${this.options.visibleItems * this.options.itemHeight}px`; + this.scrollContainer.style.overflowY = 'hidden'; + return; + } + this.totalHeight = this.items.length * this.options.itemHeight; this.contentContainer.style.height = `${this.totalHeight}px`; @@ -1892,6 +2037,16 @@ class AutoComplete { updateVisibleItems() { if (!this.scrollContainer || !this.contentContainer) return; + if (this._containsInformationalItems()) { + this.contentContainer.innerHTML = ''; + if (this.items[0]) { + this.contentContainer.appendChild( + this.createItemElement(this.items[0], 0, false, false) + ); + } + return; + } + const scrollTop = this.scrollContainer.scrollTop; const containerHeight = this.scrollContainer.clientHeight; @@ -1971,7 +2126,9 @@ class AutoComplete { isCommand = true; } - if (isCommand) { + if (isWildcardInfoItem(itemData)) { + this._renderInformationalItem(item, itemData); + } else if (isCommand) { // Render command item const cmdSpan = document.createElement('span'); cmdSpan.className = 'lm-autocomplete-command-name'; @@ -2012,6 +2169,10 @@ class AutoComplete { // Click handler item.addEventListener('click', () => { + if (isWildcardInfoItem(itemData)) { + return; + } + if (isCommand) { this._insertCommand(itemData.command); } else { @@ -2101,6 +2262,7 @@ class AutoComplete { // Clear items to prevent stale data from being displayed // when autocomplete is shown again this.items = []; + this.wildcardMeta = null; // Clear content container to prevent stale items from showing if (this.contentContainer) { @@ -2189,7 +2351,7 @@ class AutoComplete { item.scrollIntoView({ block: 'nearest' }); // Show preview for selected item - if (this.options.showPreview) { + if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) { if (typeof this.behavior.showPreview === 'function') { this.behavior.showPreview(this, this.items[index], item); } else if (this.previewTooltip) { @@ -2216,7 +2378,7 @@ class AutoComplete { selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; // Show preview for selected item - if (this.options.showPreview) { + if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) { if (typeof this.behavior.showPreview === 'function') { this.behavior.showPreview(this, this.items[index], selectedEl); } else if (this.previewTooltip) { @@ -2289,8 +2451,15 @@ class AutoComplete { // Insert command this._insertCommand(this.items[this.selectedIndex].command); } else { - // Insert selection (handle enriched items) const selectedItem = this.items[this.selectedIndex]; + if (isWildcardInfoItem(selectedItem)) { + if (selectedItem.type === 'wildcard_empty_state') { + this._openWildcardsFolder(); + } + break; + } + + // Insert selection (handle enriched items) const insertPath = typeof selectedItem === 'object' && 'tag_name' in selectedItem ? selectedItem.tag_name : selectedItem; diff --git a/web/comfyui/autocomplete_wildcards.js b/web/comfyui/autocomplete_wildcards.js index 78b796e0..314a36c7 100644 --- a/web/comfyui/autocomplete_wildcards.js +++ b/web/comfyui/autocomplete_wildcards.js @@ -2,6 +2,11 @@ export const WILDCARD_COMMANDS = { '/wildcard': { type: 'wildcard', label: 'Wildcards' }, }; +export const WILDCARD_INFO_ITEM_TYPES = { + EMPTY_STATE: 'wildcard_empty_state', + NO_MATCHES: 'wildcard_no_matches', +}; + export function isWildcardCommand(command) { return command?.type === 'wildcard'; } @@ -17,3 +22,33 @@ export function getWildcardInsertText(relativePath = '') { } return `__${trimmed}__`; } + +export function isWildcardInfoItem(item) { + return Boolean( + item && + typeof item === 'object' && + Object.values(WILDCARD_INFO_ITEM_TYPES).includes(item.type) + ); +} + +export function createWildcardEmptyStateItem(meta = {}) { + return { + type: WILDCARD_INFO_ITEM_TYPES.EMPTY_STATE, + title: 'No wildcards found yet', + description: 'Create wildcard files in your wildcards folder, then use /wildcard to search and insert keys.', + wildcardsDir: meta.wildcards_dir || '', + supportedFormats: Array.isArray(meta.supported_formats) ? meta.supported_formats : [], + }; +} + +export function createWildcardNoMatchesItem(searchTerm = '', meta = {}) { + return { + type: WILDCARD_INFO_ITEM_TYPES.NO_MATCHES, + title: 'No wildcard matches', + description: searchTerm + ? `No wildcard keys matched "${searchTerm}".` + : 'No wildcard keys matched your search.', + wildcardsDir: meta.wildcards_dir || '', + supportedFormats: Array.isArray(meta.supported_formats) ? meta.supported_formats : [], + }; +}