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
- 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:

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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",

View File

@@ -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');

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)]]
@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 = []

View File

@@ -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")

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"]
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()

View File

@@ -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;

View File

@@ -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 : [],
};
}