fix(autocomplete): improve wildcard onboarding UX

This commit is contained in:
Will Miao
2026-04-15 22:18:25 +08:00
parent 439679e15f
commit cdd77029b6
10 changed files with 573 additions and 97 deletions

View File

@@ -253,6 +253,41 @@ pip install -r requirements.txt
- Paste into the Lora Loader node's text input - Paste into the Lora Loader node's text input
- The node will automatically apply preset strength and trigger words - 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 ### 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: The Save Image Node supports dynamic filename generation using pattern codes. You can customize how your images are named using the following format patterns:

View File

@@ -2411,6 +2411,16 @@ class FileSystemHandler:
logger.error("Failed to open backup location: %s", exc, exc_info=True) logger.error("Failed to open backup location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) 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: class CustomWordsHandler:
"""Handler for autocomplete via TagFTSIndex.""" """Handler for autocomplete via TagFTSIndex."""
@@ -2507,8 +2517,19 @@ class WildcardsHandler:
search_term = request.query.get("search", "") search_term = request.query.get("search", "")
limit = min(int(request.query.get("limit", "20")), 100) limit = min(int(request.query.get("limit", "20")), 100)
offset = max(0, int(request.query.get("offset", "0"))) 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) 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: except Exception as exc:
logger.error("Error searching wildcards: %s", exc, exc_info=True) logger.error("Error searching wildcards: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
@@ -2801,6 +2822,7 @@ class MiscHandlerSet:
"open_file_location": self.filesystem.open_file_location, "open_file_location": self.filesystem.open_file_location,
"open_settings_location": self.filesystem.open_settings_location, "open_settings_location": self.filesystem.open_settings_location,
"open_backup_location": self.filesystem.open_backup_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_custom_words": self.custom_words.search_custom_words,
"search_wildcards": self.wildcards.search_wildcards, "search_wildcards": self.wildcards.search_wildcards,
"get_supporters": self.supporters.get_supporters, "get_supporters": self.supporters.get_supporters,

View File

@@ -31,6 +31,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/health-check", "health_check"), RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"), RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"), 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/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"), RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"), RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),

View File

@@ -55,6 +55,13 @@ class WildcardEntry:
values_count: int values_count: int
@dataclass(frozen=True)
class WildcardMetadata:
has_wildcards: bool
wildcards_dir: str
supported_formats: tuple[str, ...]
class WildcardService: class WildcardService:
"""Discover wildcard keys and expand wildcard syntax.""" """Discover wildcard keys and expand wildcard syntax."""
@@ -134,6 +141,14 @@ class WildcardService:
for key, values in sorted(self.get_wildcard_dict().items()) 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], ...]: def _build_signature(self) -> tuple[tuple[str, int, int], ...]:
root = get_wildcards_dir(create=False) root = get_wildcards_dir(create=False)
if not os.path.isdir(root): if not os.path.isdir(root):
@@ -405,6 +420,7 @@ def get_wildcard_service() -> WildcardService:
__all__ = [ __all__ = [
"WildcardService", "WildcardService",
"WildcardMetadata",
"contains_dynamic_syntax", "contains_dynamic_syntax",
"get_wildcard_service", "get_wildcard_service",
"get_wildcards_dir", "get_wildcards_dir",

View File

@@ -1209,7 +1209,15 @@ describe('AutoComplete widget interactions', () => {
vi.useFakeTimers(); vi.useFakeTimers();
fetchApiMock.mockResolvedValue({ 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'); caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard cat');
@@ -1237,6 +1245,129 @@ describe('AutoComplete widget interactions', () => {
expect(autoComplete.items).toEqual(['animals/cat']); 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 () => { it('inserts wildcard references when accepting a /wildcard result', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat'); caretHelperInstance.getBeforeCursor.mockReturnValue('/wildcard animals/cat');

View File

@@ -499,6 +499,38 @@ async def test_open_backup_location_uses_settings_directory(tmp_path, monkeypatc
assert calls == [["xdg-open", str(backup_dir)]] 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: class RecordingRouter:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from types import SimpleNamespace
import pytest import pytest
@@ -15,6 +16,14 @@ class FakeRequest:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_wildcards_returns_results(): async def test_search_wildcards_returns_results():
class StubService: 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): def search_keys(self, search_term, limit, offset):
assert search_term == "cat" assert search_term == "cat"
assert limit == 25 assert limit == 25
@@ -28,12 +37,27 @@ async def test_search_wildcards_returns_results():
payload = json.loads(response.text) payload = json.loads(response.text)
assert response.status == 200 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 @pytest.mark.asyncio
async def test_search_wildcards_handles_errors(): async def test_search_wildcards_handles_errors():
class StubService: 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): def search_keys(self, search_term, limit, offset):
raise RuntimeError("boom") raise RuntimeError("boom")

View File

@@ -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"] 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): def test_expand_text_resolves_nested_wildcards(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path) service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir() wildcards_dir.mkdir()

View File

@@ -3,9 +3,12 @@ import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js"; import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
import { import {
WILDCARD_COMMANDS, WILDCARD_COMMANDS,
createWildcardEmptyStateItem,
createWildcardNoMatchesItem,
getWildcardInsertText, getWildcardInsertText,
getWildcardSearchEndpoint, getWildcardSearchEndpoint,
isWildcardCommand, isWildcardCommand,
isWildcardInfoItem,
} from "./autocomplete_wildcards.js"; } from "./autocomplete_wildcards.js";
import { import {
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
@@ -363,6 +366,7 @@ class AutoComplete {
this.debounceTimer = null; this.debounceTimer = null;
this.isVisible = false; this.isVisible = false;
this.currentSearchTerm = ''; this.currentSearchTerm = '';
this.wildcardMeta = null;
this.previewTooltip = null; this.previewTooltip = null;
this.previewTooltipPromise = null; this.previewTooltipPromise = null;
this.searchType = 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(); this.hide();
return; return;
} }
@@ -1005,12 +1014,24 @@ class AutoComplete {
return uniqueQueries; 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) * Get display text for an item (without extension for models)
* @param {string|Object} item - Item to get display text from * @param {string|Object} item - Item to get display text from
* @returns {string} - Display text without extension * @returns {string} - Display text without extension
*/ */
_getDisplayText(item) { _getDisplayText(item) {
if (isWildcardInfoItem(item)) {
return item.title || item.description || 'Wildcards';
}
const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item); const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item);
// Remove extension for models to avoid matching/displaying .safetensors etc. // Remove extension for models to avoid matching/displaying .safetensors etc.
if (this.modelType === 'loras' || this.searchType === 'embeddings') { if (this.modelType === 'loras' || this.searchType === 'embeddings') {
@@ -1159,6 +1180,7 @@ class AutoComplete {
// This is critical for preventing command suggestions from persisting // This is critical for preventing command suggestions from persisting
// when switching from command mode to regular tag search // when switching from command mode to regular tag search
this.items = []; this.items = [];
this.wildcardMeta = null;
if (!endpoint) { if (!endpoint) {
endpoint = `/lm/${this.modelType}/relative-paths`; endpoint = `/lm/${this.modelType}/relative-paths`;
@@ -1167,7 +1189,10 @@ class AutoComplete {
// Generate multiple query variations for better matching, but avoid // Generate multiple query variations for better matching, but avoid
// sending duplicate-equivalent requests that normalize to the same // sending duplicate-equivalent requests that normalize to the same
// backend search term. // backend search term.
const queriesToExecute = this._getQueriesToExecute(term); const queriesToExecute =
this.searchType === 'wildcards' && term.length === 0
? ['']
: this._getQueriesToExecute(term);
if (queriesToExecute.length === 0) { if (queriesToExecute.length === 0) {
this.items = []; this.items = [];
@@ -1184,10 +1209,16 @@ class AutoComplete {
try { try {
const response = await api.fetchApi(url); const response = await api.fetchApi(url);
const data = await response.json(); 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) { } catch (error) {
console.warn(`Search query failed for "${query}":`, 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 seen = new Set();
const mergedItems = []; 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) { for (const item of resultArray) {
const itemKey = typeof item === 'object' && item.tag_name const itemKey = typeof item === 'object' && item.tag_name
? item.tag_name.toLowerCase() ? 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 // Use backend-sorted results directly without re-scoring
// Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost // Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost
if (mergedItems.length > 0) { 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]; const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];
this.items.forEach((itemData, index) => { this.items.forEach((itemData, index) => {
const item = document.createElement('div'); const item = this.createItemElement(itemData, index, isEnriched, isCommand);
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);
}
});
this.dropdown.appendChild(item); this.dropdown.appendChild(item);
}); });
@@ -1665,6 +1628,124 @@ class AutoComplete {
itemEl.appendChild(metaSpan); 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 = [
'<div style="font-weight: 600; margin-bottom: 4px;">Wildcards folder</div>',
`<code style="word-break: break-all; color: #dbeafe;">${itemData.wildcardsDir || '(unavailable)'}</code>`,
`<div style="margin-top: 6px; color: rgba(226, 232, 240, 0.68);">Supported formats: ${(itemData.supportedFormats || []).join(', ')}</div>`,
].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 = [
'<div style="font-weight: 600; color: rgba(226, 232, 240, 0.88); margin-bottom: 4px;">Examples</div>',
'<div><code>animals/cat.txt</code> -> use <code>__animals/cat__</code></div>',
'<div><code>colors.yaml</code> with <code>palette: { warm: [red, orange] }</code> -> use <code>__palette/warm__</code></div>',
'<div style="margin-top: 6px;">Text files use one option per line. YAML/JSON use nested keys ending in string arrays.</div>',
].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) { highlightMatch(text, searchTerm) {
const { include } = parseSearchTokens(searchTerm); const { include } = parseSearchTokens(searchTerm);
const sanitizedTokens = include const sanitizedTokens = include
@@ -1682,6 +1763,62 @@ class AutoComplete {
); );
} }
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) { showPreviewForItem(relativePath, itemElement) {
if (!this.options.showPreview || !this.previewTooltip) return; if (!this.options.showPreview || !this.previewTooltip) return;
@@ -1874,6 +2011,14 @@ class AutoComplete {
updateVirtualScrollHeight() { updateVirtualScrollHeight() {
if (!this.contentContainer || !this.scrollContainer) return; 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.totalHeight = this.items.length * this.options.itemHeight;
this.contentContainer.style.height = `${this.totalHeight}px`; this.contentContainer.style.height = `${this.totalHeight}px`;
@@ -1892,6 +2037,16 @@ class AutoComplete {
updateVisibleItems() { updateVisibleItems() {
if (!this.scrollContainer || !this.contentContainer) return; 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 scrollTop = this.scrollContainer.scrollTop;
const containerHeight = this.scrollContainer.clientHeight; const containerHeight = this.scrollContainer.clientHeight;
@@ -1971,7 +2126,9 @@ class AutoComplete {
isCommand = true; isCommand = true;
} }
if (isCommand) { if (isWildcardInfoItem(itemData)) {
this._renderInformationalItem(item, itemData);
} else if (isCommand) {
// Render command item // Render command item
const cmdSpan = document.createElement('span'); const cmdSpan = document.createElement('span');
cmdSpan.className = 'lm-autocomplete-command-name'; cmdSpan.className = 'lm-autocomplete-command-name';
@@ -2012,6 +2169,10 @@ class AutoComplete {
// Click handler // Click handler
item.addEventListener('click', () => { item.addEventListener('click', () => {
if (isWildcardInfoItem(itemData)) {
return;
}
if (isCommand) { if (isCommand) {
this._insertCommand(itemData.command); this._insertCommand(itemData.command);
} else { } else {
@@ -2101,6 +2262,7 @@ class AutoComplete {
// Clear items to prevent stale data from being displayed // Clear items to prevent stale data from being displayed
// when autocomplete is shown again // when autocomplete is shown again
this.items = []; this.items = [];
this.wildcardMeta = null;
// Clear content container to prevent stale items from showing // Clear content container to prevent stale items from showing
if (this.contentContainer) { if (this.contentContainer) {
@@ -2189,7 +2351,7 @@ class AutoComplete {
item.scrollIntoView({ block: 'nearest' }); item.scrollIntoView({ block: 'nearest' });
// Show preview for selected item // Show preview for selected item
if (this.options.showPreview) { if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
if (typeof this.behavior.showPreview === 'function') { if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], item); this.behavior.showPreview(this, this.items[index], item);
} else if (this.previewTooltip) { } else if (this.previewTooltip) {
@@ -2216,7 +2378,7 @@ class AutoComplete {
selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)'; selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Show preview for selected item // Show preview for selected item
if (this.options.showPreview) { if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
if (typeof this.behavior.showPreview === 'function') { if (typeof this.behavior.showPreview === 'function') {
this.behavior.showPreview(this, this.items[index], selectedEl); this.behavior.showPreview(this, this.items[index], selectedEl);
} else if (this.previewTooltip) { } else if (this.previewTooltip) {
@@ -2289,8 +2451,15 @@ class AutoComplete {
// Insert command // Insert command
this._insertCommand(this.items[this.selectedIndex].command); this._insertCommand(this.items[this.selectedIndex].command);
} else { } else {
// Insert selection (handle enriched items)
const selectedItem = this.items[this.selectedIndex]; 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 const insertPath = typeof selectedItem === 'object' && 'tag_name' in selectedItem
? selectedItem.tag_name ? selectedItem.tag_name
: selectedItem; : selectedItem;

View File

@@ -2,6 +2,11 @@ export const WILDCARD_COMMANDS = {
'/wildcard': { type: 'wildcard', label: 'Wildcards' }, '/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) { export function isWildcardCommand(command) {
return command?.type === 'wildcard'; return command?.type === 'wildcard';
} }
@@ -17,3 +22,33 @@ export function getWildcardInsertText(relativePath = '') {
} }
return `__${trimmed}__`; 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 : [],
};
}