mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-06 08:26:45 -03:00
fix(autocomplete): improve wildcard onboarding UX
This commit is contained in:
35
README.md
35
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = [
|
||||
'<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) {
|
||||
const { include } = parseSearchTokens(searchTerm);
|
||||
@@ -1681,6 +1762,62 @@ class AutoComplete {
|
||||
'<span style="background-color: rgba(66, 153, 225, 0.3); color: white; padding: 1px 2px; border-radius: 2px;">$1</span>',
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 : [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user