Compare commits

...

10 Commits

Author SHA1 Message Date
Will Miao
c4fa1631ee chore: bump version to v1.0.3 2026-04-15 23:10:43 +08:00
Will Miao
506d763dc2 chore: add pyyaml dependency 2026-04-15 23:07:36 +08:00
Will Miao
a2cd09b619 docs: add v1.0.3 release notes 2026-04-15 22:52:04 +08:00
Will Miao
cdd77029b6 fix(autocomplete): improve wildcard onboarding UX 2026-04-15 22:25:25 +08:00
Will Miao
439679e15f fix(autocomplete): preserve manual accept-key selection 2026-04-15 21:19:00 +08:00
Will Miao
2640258902 fix(prompt): invalidate dynamic wildcard cache without seed (#895) 2026-04-15 20:43:21 +08:00
Will Miao
b910388d54 fix(autocomplete): remove short prompt command aliases (#895) 2026-04-15 20:43:03 +08:00
Will Miao
083de395b1 chore(logging): remove autocomplete debug logs (#895) 2026-04-15 20:42:55 +08:00
Will Miao
4514ca94b7 fix(autocomplete): reduce tag search overhead (#895) 2026-04-15 20:42:33 +08:00
Will Miao
62247bdd87 feat(prompt): expand wildcards at runtime (#895) 2026-04-15 20:42:27 +08:00
24 changed files with 1966 additions and 226 deletions

View File

@@ -56,6 +56,15 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
## Release Notes ## Release Notes
### v1.0.3
* **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations.
* **Wildcard Support for LM Text/Prompt Nodes** - The LM `Text` node and `Prompt` node now support the new `/wildcard` command, with runtime wildcard expansion and support for dynamic prompt syntax for more flexible prompt construction.
* **System Diagnostics ("Doctor")** - Added a new diagnostics feature to help surface environment and setup issues more clearly.
* **User-State Backup Support** - Added backup support for user state, with accompanying UI and clearer backup scope messaging in Settings.
* **Downloaded Status Visibility** - Added clearer downloaded-status UX so previously downloaded model versions are easier to recognize.
* **Autocomplete Performance Improvements** - Fixed autocomplete performance issues to reduce tag-search overhead and improve responsiveness.
### v1.0.2 ### v1.0.2
* **Model Download History Tracking** - LoRA Manager now keeps a history of downloaded model versions, allowing it to recognize whether a version has been downloaded before, even if it is no longer currently present in your library. * **Model Download History Tracking** - LoRA Manager now keeps a history of downloaded model versions, allowing it to recognize whether a version has been downloaded before, even if it is no longer currently present in your library.
@@ -101,7 +110,7 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
### v0.9.14 ### v0.9.14
* **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example. * **LoRA Cycler Node** - Introduced a new LoRA Cycler node that enables iteration through specified LoRAs with support for repeat count and pause iteration functionality. Refer to the new "Lora Cycler" template workflow for concrete example.
* **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/char` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips. * **Enhanced Prompt Node with Tag Autocomplete** - Enhanced the Prompt node with comprehensive tag autocomplete based on merged Danbooru + e621 tags. Supports tag search and autocomplete functionality. Implemented a command system with shortcuts like `/character` or `/artist` for category-specific tag searching. Added `/ac` or `/noac` commands to quickly enable or disable autocomplete. Refer to the "Lora Manager Basic" template workflow in ComfyUI -> Templates -> ComfyUI-Lora-Manager for detailed tips.
* **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability. * **Bug Fixes & Stability** - Addressed multiple bugs and improved overall stability.
### v0.9.12 ### v0.9.12
@@ -253,6 +262,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:

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,38 @@
from __future__ import annotations
from typing import Any from typing import Any
import inspect import inspect
from ..services.wildcard_service import (
contains_dynamic_syntax,
get_wildcard_service,
is_trigger_words_input,
)
class _AllContainer:
"""Container that accepts any key for dynamic input validation."""
def __contains__(self, item): class _PromptOptionalInputs:
return True """Lookup that preserves explicit optional inputs and dynamic trigger slots."""
def __getitem__(self, key): def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
return ("STRING", {"forceInput": True}) self._explicit_inputs = explicit_inputs
def __contains__(self, item: object) -> bool:
if not isinstance(item, str):
return False
return item in self._explicit_inputs or is_trigger_words_input(item)
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
if key in self._explicit_inputs:
return self._explicit_inputs[key]
if is_trigger_words_input(key):
return (
"STRING",
{
"forceInput": True,
"tooltip": "Trigger words to prepend. Connect to add more inputs.",
},
)
raise KeyError(key)
class PromptLM: class PromptLM:
@@ -20,12 +43,19 @@ class PromptLM:
DESCRIPTION = ( DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used " "Encodes a text prompt using a CLIP model into an embedding that can be used "
"to guide the diffusion model towards generating specific images. " "to guide the diffusion model towards generating specific images. "
"Supports dynamic trigger words inputs." "Supports dynamic trigger words inputs and runtime wildcard expansion."
) )
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
dyn_inputs = { optional_inputs: dict[str, tuple[str, dict[str, Any]]] = {
"seed": (
"INT",
{
"forceInput": True,
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
},
),
"trigger_words1": ( "trigger_words1": (
"STRING", "STRING",
{ {
@@ -35,10 +65,9 @@ class PromptLM:
), ),
} }
# Bypass validation for dynamic inputs during graph execution
stack = inspect.stack() stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info": if len(stack) > 2 and stack[2].function == "get_input_info":
dyn_inputs = _AllContainer() optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
return { return {
"required": { "required": {
@@ -46,8 +75,8 @@ class PromptLM:
"AUTOCOMPLETE_TEXT_PROMPT,STRING", "AUTOCOMPLETE_TEXT_PROMPT,STRING",
{ {
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT", "widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
"placeholder": "Enter prompt... /char, /artist for quick tag search", "placeholder": "Enter prompt... /character, /artist, /wildcard for quick search",
"tooltip": "The text to be encoded.", "tooltip": "The text to be encoded. Wildcard references inserted with /wildcard are expanded at runtime.",
}, },
), ),
"clip": ( "clip": (
@@ -55,7 +84,7 @@ class PromptLM:
{"tooltip": "The CLIP model used for encoding the text."}, {"tooltip": "The CLIP model used for encoding the text."},
), ),
}, },
"optional": dyn_inputs, "optional": optional_inputs,
} }
RETURN_TYPES = ("CONDITIONING", "STRING") RETURN_TYPES = ("CONDITIONING", "STRING")
@@ -65,20 +94,39 @@ class PromptLM:
) )
FUNCTION = "encode" FUNCTION = "encode"
def encode(self, text: str, clip: Any, **kwargs): @classmethod
# Collect all trigger words from dynamic inputs def IS_CHANGED(
cls,
text: str,
clip: Any | None = None,
seed: int | None = None,
**kwargs: Any,
):
del clip, kwargs
if contains_dynamic_syntax(text) and seed is None:
return float("NaN")
return False
def encode(
self,
text: str,
clip: Any,
seed: int | None = None,
**kwargs: Any,
):
expanded_text = get_wildcard_service().expand_text(text, seed=seed)
trigger_words = [] trigger_words = []
for key, value in kwargs.items(): for key, value in kwargs.items():
if key.startswith("trigger_words") and value: if is_trigger_words_input(key) and value:
trigger_words.append(value) trigger_words.append(value)
# Build final prompt
if trigger_words: if trigger_words:
prompt = ", ".join(trigger_words + [text]) prompt = ", ".join(trigger_words + [expanded_text])
else: else:
prompt = text prompt = expanded_text
from nodes import CLIPTextEncode # type: ignore from nodes import CLIPTextEncode # type: ignore
conditioning = CLIPTextEncode().encode(clip, prompt)[0] conditioning = CLIPTextEncode().encode(clip, prompt)[0]
return (conditioning, prompt) return (conditioning, prompt)

View File

@@ -1,10 +1,15 @@
from __future__ import annotations
from ..services.wildcard_service import contains_dynamic_syntax, get_wildcard_service
class TextLM: class TextLM:
"""A simple text node with autocomplete support.""" """A simple text node with autocomplete support."""
NAME = "Text (LoraManager)" NAME = "Text (LoraManager)"
CATEGORY = "Lora Manager/utils" CATEGORY = "Lora Manager/utils"
DESCRIPTION = ( DESCRIPTION = (
"A simple text input node with autocomplete support for tags and styles." "A simple text input node with autocomplete support for tags, styles, and wildcard expansion."
) )
@classmethod @classmethod
@@ -15,8 +20,17 @@ class TextLM:
"AUTOCOMPLETE_TEXT_PROMPT,STRING", "AUTOCOMPLETE_TEXT_PROMPT,STRING",
{ {
"widgetType": "AUTOCOMPLETE_TEXT_PROMPT", "widgetType": "AUTOCOMPLETE_TEXT_PROMPT",
"placeholder": "Enter text... /char, /artist for quick tag search", "placeholder": "Enter text... /character, /artist, /wildcard for quick search",
"tooltip": "The text output.", "tooltip": "The text output. Wildcard references inserted with /wildcard are expanded at runtime.",
},
),
},
"optional": {
"seed": (
"INT",
{
"forceInput": True,
"tooltip": "Optional seed for wildcard generation. Leave unconnected for non-deterministic wildcard expansion.",
}, },
), ),
}, },
@@ -24,10 +38,14 @@ class TextLM:
RETURN_TYPES = ("STRING",) RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("STRING",) RETURN_NAMES = ("STRING",)
OUTPUT_TOOLTIPS = ( OUTPUT_TOOLTIPS = ("The text output.",)
"The text output.",
)
FUNCTION = "process" FUNCTION = "process"
def process(self, text: str): @classmethod
return (text,) def IS_CHANGED(cls, text: str, seed: int | None = None):
if contains_dynamic_syntax(text) and seed is None:
return float("NaN")
return False
def process(self, text: str, seed: int | None = None):
return (get_wildcard_service().expand_text(text, seed=seed),)

View File

@@ -13,6 +13,7 @@ import contextlib
import io import io
import json import json
import logging import logging
import time
import os import os
import platform import platform
import re import re
@@ -2410,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."""
@@ -2489,6 +2500,41 @@ class CustomWordsHandler:
return None return None
class WildcardsHandler:
"""Handler for wildcard autocomplete search."""
def __init__(self, *, service=None) -> None:
if service is None:
from ...services.wildcard_service import get_wildcard_service
service = get_wildcard_service()
self._service = service
async def search_wildcards(self, request: web.Request) -> web.Response:
"""Search managed wildcard keys for autocomplete."""
try:
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,
"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)
class NodeRegistryHandler: class NodeRegistryHandler:
def __init__( def __init__(
self, self,
@@ -2717,6 +2763,7 @@ class MiscHandlerSet:
backup: BackupHandler, backup: BackupHandler,
filesystem: FileSystemHandler, filesystem: FileSystemHandler,
custom_words: CustomWordsHandler, custom_words: CustomWordsHandler,
wildcards: WildcardsHandler,
supporters: SupportersHandler, supporters: SupportersHandler,
doctor: DoctorHandler, doctor: DoctorHandler,
example_workflows: ExampleWorkflowsHandler, example_workflows: ExampleWorkflowsHandler,
@@ -2734,6 +2781,7 @@ class MiscHandlerSet:
self.backup = backup self.backup = backup
self.filesystem = filesystem self.filesystem = filesystem
self.custom_words = custom_words self.custom_words = custom_words
self.wildcards = wildcards
self.supporters = supporters self.supporters = supporters
self.doctor = doctor self.doctor = doctor
self.example_workflows = example_workflows self.example_workflows = example_workflows
@@ -2774,7 +2822,9 @@ 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,
"get_supporters": self.supporters.get_supporters, "get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows, "get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow, "get_example_workflow": self.example_workflows.get_example_workflow,

View File

@@ -30,6 +30,8 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
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("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

@@ -35,6 +35,7 @@ from .handlers.misc_handlers import (
SupportersHandler, SupportersHandler,
TrainedWordsHandler, TrainedWordsHandler,
UsageStatsHandler, UsageStatsHandler,
WildcardsHandler,
build_service_registry_adapter, build_service_registry_adapter,
) )
from .handlers.base_model_handlers import BaseModelHandlerSet from .handlers.base_model_handlers import BaseModelHandlerSet
@@ -130,6 +131,7 @@ class MiscRoutes:
metadata_provider_factory=self._metadata_provider_factory, metadata_provider_factory=self._metadata_provider_factory,
) )
custom_words = CustomWordsHandler() custom_words = CustomWordsHandler()
wildcards = WildcardsHandler()
supporters = SupportersHandler() supporters = SupportersHandler()
doctor = DoctorHandler(settings_service=self._settings) doctor = DoctorHandler(settings_service=self._settings)
example_workflows = ExampleWorkflowsHandler() example_workflows = ExampleWorkflowsHandler()
@@ -148,6 +150,7 @@ class MiscRoutes:
backup=backup, backup=backup,
filesystem=filesystem, filesystem=filesystem,
custom_words=custom_words, custom_words=custom_words,
wildcards=wildcards,
supporters=supporters, supporters=supporters,
doctor=doctor, doctor=doctor,
example_workflows=example_workflows, example_workflows=example_workflows,

View File

@@ -7,11 +7,13 @@ with category filtering and enriched results including post counts.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_EMBEDDED_COMMAND_PATTERN = re.compile(r"\s/\w")
class CustomWordsService: class CustomWordsService:
"""Service for autocomplete via TagFTSIndex. """Service for autocomplete via TagFTSIndex.
@@ -77,12 +79,28 @@ class CustomWordsService:
Returns: Returns:
List of dicts with tag_name, category, and post_count. List of dicts with tag_name, category, and post_count.
""" """
normalized_search = search_term.strip()
if not normalized_search:
return []
# Prompt widgets should only send the active token, but guard against
# accidental full-prompt queries reaching the FTS path.
if (
"__" in normalized_search
or "," in normalized_search
or ">" in normalized_search
or "\n" in normalized_search
or "\r" in normalized_search
or _EMBEDDED_COMMAND_PATTERN.search(normalized_search)
):
logger.debug("Skipping prompt-like custom words query: %s", normalized_search)
return []
tag_index = self._get_tag_index() tag_index = self._get_tag_index()
if tag_index is not None: if tag_index is not None:
results = tag_index.search( return tag_index.search(
search_term, categories=categories, limit=limit, offset=offset normalized_search, categories=categories, limit=limit, offset=offset
) )
return results
logger.debug("TagFTSIndex not available, returning empty results") logger.debug("TagFTSIndex not available, returning empty results")
return [] return []

View File

@@ -450,9 +450,9 @@ class TagFTSIndex:
the tag_name, the result will include a "matched_alias" field. the tag_name, the result will include a "matched_alias" field.
Ranking is based on a combination of: Ranking is based on a combination of:
1. FTS5 bm25 relevance score (how well the text matches) 1. Exact prefix match boost (tag_name starts with query)
2. Post count (popularity) 2. Post count to preserve expected autocomplete ordering
3. Exact prefix match boost (tag_name starts with query) 3. FTS5 bm25 relevance score as a deterministic tie-breaker
Args: Args:
query: The search query string. query: The search query string.
@@ -484,65 +484,17 @@ class TagFTSIndex:
with self._lock: with self._lock:
conn = self._connect(readonly=True) conn = self._connect(readonly=True)
try: try:
# Build the SQL query with bm25 ranking sql, params = self._build_search_statement(
# FTS5 bm25() returns negative scores, lower is better query_lower=query_lower,
# We use -bm25() to get higher=better scores fts_query=fts_query,
# Weights: -100.0 for exact matches, 1.0 for others categories=categories,
# Add LOG10(post_count) weighting to boost popular tags limit=limit,
# Use CASE to boost tag_name prefix matches above alias matches offset=offset,
if categories: )
placeholders = ",".join("?" * len(categories))
sql = f"""
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
AND t.category IN ({placeholders})
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
# Escape special LIKE characters and add wildcard
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = (
[query_escaped + "%", fts_query]
+ categories
+ [limit, offset]
)
else:
sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = [query_escaped + "%", fts_query, limit, offset]
cursor = conn.execute(sql, params) cursor = conn.execute(sql, params)
rows = cursor.fetchall()
results = [] results = []
for row in cursor.fetchall(): for row in rows:
result = { result = {
"tag_name": row[0], "tag_name": row[0],
"category": row[1], "category": row[1],
@@ -571,6 +523,62 @@ class TagFTSIndex:
logger.debug("Tag FTS search error for query '%s': %s", query, exc) logger.debug("Tag FTS search error for query '%s': %s", query, exc)
return [] return []
def _build_search_statement(
self,
query_lower: str,
fts_query: str,
categories: Optional[List[int]],
limit: int,
offset: int,
) -> tuple[str, list[object]]:
"""Build the SQL statement and params for a tag search."""
# Escape special LIKE characters and add wildcard
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
# FTS5 bm25() returns negative scores, lower is better.
# We use -bm25() to get higher=better scores, but keep post_count as the
# primary sort within tag-name prefix matches so autocomplete ordering
# remains aligned with the existing popularity-first behavior.
if categories:
placeholders = ",".join("?" * len(categories))
sql = f"""
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
FROM tag_fts
CROSS JOIN tags t ON t.rowid = tag_fts.rowid
WHERE tag_fts.searchable_text MATCH ?
AND t.category IN ({placeholders})
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
params = [query_escaped + "%", fts_query] + categories + [limit, offset]
else:
sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases,
CASE
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
ELSE 0
END AS is_tag_name_match,
bm25(tag_fts, -100.0, 1.0, 1.0) AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC
LIMIT ? OFFSET ?
"""
params = [query_escaped + "%", fts_query, limit, offset]
return sql, params
def _find_matched_alias( def _find_matched_alias(
self, query: str, tag_name: str, aliases_str: str self, query: str, tag_name: str, aliases_str: str
) -> Optional[str]: ) -> Optional[str]:

View File

@@ -0,0 +1,428 @@
"""Managed wildcard loading, search, and text expansion."""
from __future__ import annotations
import json
import logging
import os
import random
import re
from dataclasses import dataclass
from typing import Any, Optional
import yaml
from ..utils.settings_paths import get_settings_dir
logger = logging.getLogger(__name__)
_WILDCARD_PATTERN = re.compile(r"__([\w\s.\-+/*\\]+?)__")
_OPTION_PATTERN = re.compile(r"{([^{}]*?)}")
_TRIGGER_WORD_PATTERN = re.compile(r"^trigger_words\d+$")
_WEIGHTED_OPTION_PATTERN = re.compile(r"^\s*([0-9.]+)::")
_NUMERIC_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
def _normalize_wildcard_key(value: str) -> str:
return value.replace("\\", "/").strip("/").lower()
def _is_numeric_string(value: str) -> bool:
return bool(_NUMERIC_PATTERN.match(value))
def contains_dynamic_syntax(text: str) -> bool:
"""Return True when text contains supported wildcard or option syntax."""
return isinstance(text, str) and bool(
_WILDCARD_PATTERN.search(text) or _OPTION_PATTERN.search(text)
)
def get_wildcards_dir(create: bool = False) -> str:
"""Return the managed wildcard directory inside the settings folder."""
settings_dir = get_settings_dir(create=create)
wildcards_dir = os.path.join(settings_dir, "wildcards")
if create:
os.makedirs(wildcards_dir, exist_ok=True)
return wildcards_dir
@dataclass(frozen=True)
class WildcardEntry:
key: str
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."""
_instance: Optional["WildcardService"] = None
def __new__(cls) -> "WildcardService":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self) -> None:
if getattr(self, "_initialized", False):
return
self._initialized = True
self._cached_signature: tuple[tuple[str, int, int], ...] | None = None
self._wildcard_dict: dict[str, list[str]] = {}
@classmethod
def get_instance(cls) -> "WildcardService":
return cls()
def search_keys(
self, search_term: str, limit: int = 20, offset: int = 0
) -> list[str]:
"""Search wildcard keys for autocomplete."""
normalized_term = _normalize_wildcard_key(search_term).strip()
if not normalized_term:
return []
ranked: list[tuple[int, str]] = []
compact_term = normalized_term.replace("/", "")
for key in self.get_wildcard_dict().keys():
score = self._score_entry(key, normalized_term, compact_term)
if score is not None:
ranked.append((score, key))
ranked.sort(key=lambda item: (-item[0], item[1]))
keys = [key for _, key in ranked]
return keys[offset : offset + limit]
def expand_text(self, text: str, seed: int | None = None) -> str:
"""Expand wildcard and dynamic prompt syntax for a text value."""
if not isinstance(text, str) or not text:
return text
rng = random.Random(seed) if seed is not None else random.Random()
wildcard_dict = self.get_wildcard_dict()
if not wildcard_dict:
return self._expand_options_only(text, rng)
current = text
remaining_depth = 100
while remaining_depth > 0:
remaining_depth -= 1
after_options, options_replaced = self._replace_options(current, rng)
current, wildcards_replaced = self._replace_wildcards(
after_options, rng, wildcard_dict
)
if not options_replaced and not wildcards_replaced:
break
return current
def get_wildcard_dict(self) -> dict[str, list[str]]:
signature = self._build_signature()
if signature != self._cached_signature:
self._wildcard_dict = self._scan_wildcard_dict()
self._cached_signature = signature
return self._wildcard_dict
def get_entries(self) -> list[WildcardEntry]:
return [
WildcardEntry(key=key, values_count=len(values))
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):
return ()
signature: list[tuple[str, int, int]] = []
for current_root, _dirs, files in os.walk(root, followlinks=True):
for file_name in sorted(files):
if not file_name.lower().endswith((".txt", ".yaml", ".yml", ".json")):
continue
file_path = os.path.join(current_root, file_name)
try:
stat = os.stat(file_path)
except OSError:
continue
rel_path = os.path.relpath(file_path, root).replace("\\", "/")
signature.append((rel_path, int(stat.st_mtime_ns), int(stat.st_size)))
signature.sort()
return tuple(signature)
def _scan_wildcard_dict(self) -> dict[str, list[str]]:
root = get_wildcards_dir(create=False)
if not os.path.isdir(root):
return {}
collected: dict[str, list[str]] = {}
for current_root, _dirs, files in os.walk(root, followlinks=True):
for file_name in sorted(files):
file_path = os.path.join(current_root, file_name)
lower_name = file_name.lower()
try:
if lower_name.endswith(".txt"):
rel_path = os.path.relpath(file_path, root)
key = _normalize_wildcard_key(os.path.splitext(rel_path)[0])
values = self._read_txt(file_path)
if values:
collected[key] = values
elif lower_name.endswith((".yaml", ".yml")):
payload = self._read_yaml(file_path)
self._merge_nested_entries(collected, payload)
elif lower_name.endswith(".json"):
payload = self._read_json(file_path)
self._merge_nested_entries(collected, payload)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Failed to load wildcard file %s: %s", file_path, exc)
return collected
def _read_txt(self, file_path: str) -> list[str]:
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as handle:
return [line.strip() for line in handle.read().splitlines() if line.strip()]
except OSError as exc:
logger.warning("Failed to read wildcard txt file %s: %s", file_path, exc)
return []
def _read_yaml(self, file_path: str) -> Any:
with open(file_path, "r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
def _read_json(self, file_path: str) -> Any:
with open(file_path, "r", encoding="utf-8") as handle:
return json.load(handle)
def _merge_nested_entries(
self, collected: dict[str, list[str]], payload: Any
) -> None:
for key, values in self._flatten_payload(payload):
collected[key] = values
def _flatten_payload(
self, payload: Any, prefix: str = ""
) -> list[tuple[str, list[str]]]:
entries: list[tuple[str, list[str]]] = []
if isinstance(payload, dict):
for key, value in payload.items():
next_prefix = f"{prefix}/{key}" if prefix else str(key)
entries.extend(self._flatten_payload(value, next_prefix))
return entries
if isinstance(payload, list):
normalized_prefix = _normalize_wildcard_key(prefix)
values = [value.strip() for value in payload if isinstance(value, str) and value.strip()]
if normalized_prefix and values:
entries.append((normalized_prefix, values))
return entries
return entries
def _score_entry(
self, key: str, normalized_term: str, compact_term: str
) -> int | None:
key_compact = key.replace("/", "")
if key == normalized_term:
return 5000
if key.startswith(normalized_term):
return 4000
if f"/{normalized_term}" in key:
return 3500
if normalized_term in key:
return 3000
if compact_term and key_compact.startswith(compact_term):
return 2500
if compact_term and compact_term in key_compact:
return 2000
return None
def _expand_options_only(self, text: str, rng: random.Random) -> str:
current = text
remaining_depth = 100
while remaining_depth > 0:
remaining_depth -= 1
current, replaced = self._replace_options(current, rng)
if not replaced:
break
return current
def _replace_options(
self, text: str, rng: random.Random
) -> tuple[str, bool]:
replaced_any = False
def replace_option(match: re.Match[str]) -> str:
nonlocal replaced_any
replacement = self._resolve_option_group(match.group(1), rng)
replaced_any = True
return replacement
return _OPTION_PATTERN.sub(replace_option, text), replaced_any
def _resolve_option_group(self, group_text: str, rng: random.Random) -> str:
options = group_text.split("|")
multi_select_pattern = options[0].split("$$")
select_range: tuple[int, int] | None = None
select_separator = " "
if len(multi_select_pattern) > 1:
count_spec = multi_select_pattern[0]
range_match = re.match(r"(\d+)(-(\d+))?$", count_spec)
shorthand_match = re.match(r"-(\d+)$", count_spec)
if range_match:
start_text = range_match.group(1)
end_text = range_match.group(3)
if end_text is not None and _is_numeric_string(start_text) and _is_numeric_string(end_text):
select_range = (int(start_text), int(end_text))
elif _is_numeric_string(start_text):
value = int(start_text)
select_range = (value, value)
elif shorthand_match:
end_text = shorthand_match.group(1)
if _is_numeric_string(end_text):
select_range = (1, int(end_text))
if select_range is not None and len(multi_select_pattern) == 2:
options[0] = multi_select_pattern[1]
elif select_range is not None and len(multi_select_pattern) >= 3:
select_separator = multi_select_pattern[1]
options[0] = multi_select_pattern[2]
weighted_options: list[tuple[float, str]] = []
for option in options:
weight = 1.0
parts = option.split("::", 1)
if len(parts) == 2 and _is_numeric_string(parts[0].strip()):
weight = float(parts[0].strip())
weighted_options.append((weight, option))
if select_range is None:
selection_count = 1
else:
selection_count = rng.randint(select_range[0], select_range[1])
if selection_count <= 1:
return self._strip_weight_prefix(self._weighted_choice(weighted_options, rng))
selection_count = min(selection_count, len(weighted_options))
selected: list[str] = []
used_indexes: set[int] = set()
while len(selected) < selection_count:
picked_index = self._weighted_choice_index(weighted_options, rng)
if picked_index in used_indexes:
if len(used_indexes) == len(weighted_options):
break
continue
used_indexes.add(picked_index)
selected.append(
self._strip_weight_prefix(weighted_options[picked_index][1])
)
return select_separator.join(selected)
def _weighted_choice(
self, weighted_options: list[tuple[float, str]], rng: random.Random
) -> str:
return weighted_options[self._weighted_choice_index(weighted_options, rng)][1]
def _weighted_choice_index(
self, weighted_options: list[tuple[float, str]], rng: random.Random
) -> int:
total_weight = sum(max(weight, 0.0) for weight, _value in weighted_options)
if total_weight <= 0:
return rng.randrange(len(weighted_options))
threshold = rng.uniform(0, total_weight)
cumulative = 0.0
for index, (weight, _value) in enumerate(weighted_options):
cumulative += max(weight, 0.0)
if threshold <= cumulative:
return index
return len(weighted_options) - 1
def _strip_weight_prefix(self, value: str) -> str:
return _WEIGHTED_OPTION_PATTERN.sub("", value, count=1)
def _replace_wildcards(
self,
text: str,
rng: random.Random,
wildcard_dict: dict[str, list[str]],
) -> tuple[str, bool]:
replaced_any = False
def replace_match(match: re.Match[str]) -> str:
nonlocal replaced_any
replacement = self._resolve_wildcard_match(match.group(1), rng, wildcard_dict)
if replacement is None:
return match.group(0)
replaced_any = True
return replacement
return _WILDCARD_PATTERN.sub(replace_match, text), replaced_any
def _resolve_wildcard_match(
self,
raw_key: str,
rng: random.Random,
wildcard_dict: dict[str, list[str]],
) -> str | None:
keyword = _normalize_wildcard_key(raw_key)
if keyword in wildcard_dict:
return rng.choice(wildcard_dict[keyword])
if "*" in keyword:
regex_pattern = keyword.replace("*", ".*").replace("+", r"\+")
compiled = re.compile(f"^{regex_pattern}$")
aggregated: list[str] = []
for key, values in wildcard_dict.items():
if compiled.match(key):
aggregated.extend(values)
if aggregated:
return rng.choice(aggregated)
if "/" not in keyword:
fallback_keyword = _normalize_wildcard_key(f"*/{keyword}")
if fallback_keyword != keyword:
return self._resolve_wildcard_match(fallback_keyword, rng, wildcard_dict)
return None
def is_trigger_words_input(name: str) -> bool:
return bool(_TRIGGER_WORD_PATTERN.match(name))
def get_wildcard_service() -> WildcardService:
return WildcardService.get_instance()
__all__ = [
"WildcardService",
"WildcardMetadata",
"contains_dynamic_syntax",
"get_wildcard_service",
"get_wildcards_dir",
"is_trigger_words_input",
]

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.2" version = "1.0.3"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
@@ -14,7 +14,8 @@ dependencies = [
"natsort", "natsort",
"GitPython", "GitPython",
"aiosqlite", "aiosqlite",
"platformdirs" "platformdirs",
"pyyaml"
] ]
[project.urls] [project.urls]

View File

@@ -11,3 +11,4 @@ GitPython
aiosqlite aiosqlite
beautifulsoup4 beautifulsoup4
platformdirs platformdirs
pyyaml

View File

@@ -126,6 +126,31 @@ describe('AutoComplete widget interactions', () => {
expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled(); expect(caretHelperInstance.getCursorOffset).toHaveBeenCalled();
}); });
it('deduplicates duplicate-equivalent query variations before issuing requests', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('Example');
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'prompt', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'Example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledTimes(1);
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=Example&limit=100');
});
it('inserts the selected LoRA with usage tip strengths and restores focus', async () => { it('inserts the selected LoRA with usage tip strengths and restores focus', async () => {
fetchApiMock.mockImplementation((url) => { fetchApiMock.mockImplementation((url) => {
if (url.includes('usage-tips-by-path')) { if (url.includes('usage-tips-by-path')) {
@@ -244,6 +269,53 @@ describe('AutoComplete widget interactions', () => {
expect(inputListener).not.toHaveBeenCalled(); expect(inputListener).not.toHaveBeenCalled();
}); });
it('shows the full command list when typing a single slash', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/character');
expect(commandNames).toContain('/artist');
expect(commandNames).toContain('/general');
expect(commandNames).toContain('/copyright');
expect(commandNames).toContain('/meta');
expect(commandNames).toContain('/species');
expect(commandNames).toContain('/lore');
expect(commandNames).toContain('/emb');
expect(commandNames).toContain('/embedding');
expect(commandNames).toContain('/wildcard');
});
it('renders every command item when slash opens the command list', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const renderedCommands = autoComplete.contentContainer.querySelectorAll('.lm-autocomplete-command-name');
expect(renderedCommands).toHaveLength(autoComplete.items.length);
});
it('accepts the selected suggestion with Enter', async () => { it('accepts the selected suggestion with Enter', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example'); caretHelperInstance.getBeforeCursor.mockReturnValue('example');
@@ -300,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
expect(insertSelectionSpy).toHaveBeenCalledWith('loop'); expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
}); });
it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => { it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop'); caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
@@ -743,12 +875,12 @@ describe('AutoComplete widget interactions', () => {
json: () => Promise.resolve({ success: true, words: mockTags }), json: () => Promise.resolve({ success: true, words: mockTags }),
}); });
// Simulate "/char looking to the side" input // Simulate "/character looking to the side" input
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side'); caretHelperInstance.getBeforeCursor.mockReturnValue('/character looking to the side');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 }); caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea'); const input = document.createElement('textarea');
input.value = '/char looking to the side'; input.value = '/character looking to the side';
input.selectionStart = input.value.length; input.selectionStart = input.value.length;
input.focus = vi.fn(); input.focus = vi.fn();
input.setSelectionRange = vi.fn(); input.setSelectionRange = vi.fn();
@@ -766,7 +898,7 @@ describe('AutoComplete widget interactions', () => {
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' }; autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
autoComplete.items = mockTags; autoComplete.items = mockTags;
autoComplete.selectedIndex = 0; autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = '/char looking to the side'; autoComplete.currentSearchTerm = '/character looking to the side';
await autoComplete.insertSelection('looking_to_the_side'); await autoComplete.insertSelection('looking_to_the_side');
@@ -1073,6 +1205,253 @@ describe('AutoComplete widget interactions', () => {
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100'); expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
}); });
it('searches wildcard keys when using the /wildcard command', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
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.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,
});
fetchApiMock.mockClear();
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/wildcards/search?search=cat&limit=100');
expect(autoComplete.searchType).toBe('wildcards');
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');
const input = document.createElement('textarea');
input.value = '/wildcard animals/cat';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'wildcards';
autoComplete.activeCommand = { type: 'wildcard', label: 'Wildcards' };
autoComplete.items = ['animals/cat'];
autoComplete.selectedIndex = 0;
await autoComplete.insertSelection('animals/cat');
expect(input.value).toBe('__animals/cat__,');
expect(input.focus).toHaveBeenCalled();
expect(input.setSelectionRange).toHaveBeenCalled();
});
it('does not reopen autocomplete on blur after inserting a wildcard literal', async () => {
const input = document.createElement('textarea');
input.value = '__flower__,';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__,');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
const hideSpy = vi.spyOn(autoComplete, 'hide');
input.dispatchEvent(new Event('blur', { bubbles: true }));
expect(fetchApiMock).not.toHaveBeenCalled();
expect(hideSpy).toHaveBeenCalled();
expect(autoComplete.isVisible).toBe(false);
});
it('treats a command after a wildcard literal as the active token', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [{ tag_name: 'flower_field', category: 4, post_count: 1234 }],
}),
});
const input = document.createElement('textarea');
input.value = '__flower__ /character f';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('__flower__ /character f');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
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.getSearchTerm(input.value)).toBe('/character f');
});
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => { it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
settingGetMock.mockImplementation((key) => { settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') { if (key === 'loramanager.autocomplete_append_comma') {

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from py.nodes.prompt import PromptLM
from py.nodes.text import TextLM
def test_text_lm_expands_wildcards_before_output(monkeypatch):
node = TextLM()
expand_calls = []
class StubService:
def expand_text(self, text, seed=None):
expand_calls.append((text, seed))
return "expanded text"
monkeypatch.setattr("py.nodes.text.get_wildcard_service", lambda: StubService())
assert node.process("__flower__", seed=9) == ("expanded text",)
assert expand_calls == [("__flower__", 9)]
def test_prompt_lm_expands_before_appending_trigger_words(monkeypatch):
node = PromptLM()
class StubService:
def expand_text(self, text, seed=None):
assert text == "__flower__"
assert seed == 42
return "rose"
class StubEncoder:
def encode(self, clip, prompt):
assert clip == "clip"
assert prompt == "artist style, rose"
return ("conditioning",)
monkeypatch.setattr("py.nodes.prompt.get_wildcard_service", lambda: StubService())
monkeypatch.setattr("nodes.CLIPTextEncode", lambda: StubEncoder(), raising=False)
result = node.encode("__flower__", "clip", seed=42, trigger_words1="artist style")
assert result == ("conditioning", "artist style, rose")
def test_prompt_lm_input_types_expose_input_only_seed():
input_types = PromptLM.INPUT_TYPES()
seed_type, seed_options = input_types["optional"]["seed"]
assert seed_type == "INT"
assert seed_options["forceInput"] is True
assert "wildcard generation" in seed_options["tooltip"]
def test_text_lm_input_types_expose_input_only_seed():
input_types = TextLM.INPUT_TYPES()
seed_type, seed_options = input_types["optional"]["seed"]
assert seed_type == "INT"
assert seed_options["forceInput"] is True
assert "wildcard generation" in seed_options["tooltip"]
def test_text_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
result = TextLM.IS_CHANGED("__flower__", seed=None)
assert result != result
def test_text_lm_is_changed_keeps_cache_for_seeded_or_static_text():
assert TextLM.IS_CHANGED("__flower__", seed=7) is False
assert TextLM.IS_CHANGED("plain text", seed=None) is False
assert TextLM.IS_CHANGED("{red|blue}", seed=7) is False
def test_prompt_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
result = PromptLM.IS_CHANGED("{red|blue}", clip="clip", seed=None)
assert result != result
def test_prompt_lm_is_changed_keeps_cache_for_seeded_or_static_text():
assert PromptLM.IS_CHANGED("__flower__", clip="clip", seed=11) is False
assert PromptLM.IS_CHANGED("plain text", clip="clip", seed=None) is False

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

@@ -0,0 +1,69 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from py.routes.handlers.misc_handlers import WildcardsHandler
class FakeRequest:
def __init__(self, query=None):
self.query = query or {}
@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
assert offset == 2
return ["animals/cat"]
handler = WildcardsHandler(service=StubService())
response = await handler.search_wildcards(
FakeRequest(query={"search": "cat", "limit": "25", "offset": "2"})
)
payload = json.loads(response.text)
assert response.status == 200
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")
handler = WildcardsHandler(service=StubService())
response = await handler.search_wildcards(FakeRequest(query={"search": "cat"}))
payload = json.loads(response.text)
assert response.status == 500
assert payload["error"] == "boom"

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
import json
from py.services.wildcard_service import WildcardService, contains_dynamic_syntax
def _make_service(monkeypatch, tmp_path):
settings_dir = tmp_path / "settings"
settings_dir.mkdir()
monkeypatch.setattr(
"py.services.wildcard_service.get_settings_dir",
lambda create=True: str(settings_dir),
)
service = WildcardService()
service._cached_signature = None
service._wildcard_dict = {}
return service, settings_dir / "wildcards"
def test_search_keys_returns_empty_when_directory_missing(monkeypatch, tmp_path):
service, _wildcards_dir = _make_service(monkeypatch, tmp_path)
assert service.search_keys("cat") == []
def test_search_keys_loads_txt_yaml_and_json(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "animals").mkdir()
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\nblack cat\n", encoding="utf-8")
(wildcards_dir / "colors.yaml").write_text(
"palette:\n warm:\n - red\n - orange\n",
encoding="utf-8",
)
(wildcards_dir / "artists.json").write_text(
json.dumps({"illustrators/digital": ["alice", "bob"]}),
encoding="utf-8",
)
assert service.search_keys("cat") == ["animals/cat"]
assert service.search_keys("warm") == ["palette/warm"]
assert service.search_keys("digital") == ["illustrators/digital"]
def test_search_keys_prefers_exact_and_prefix_matches(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "animals").mkdir()
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\n", encoding="utf-8")
(wildcards_dir / "animals" / "catgirl.txt").write_text("heroine\n", encoding="utf-8")
(wildcards_dir / "fantasy_cat.txt").write_text("beast\n", encoding="utf-8")
results = service.search_keys("cat")
assert results == ["animals/cat", "animals/catgirl", "fantasy_cat"]
def test_search_keys_supports_offset_and_limit(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
for name in ("cat", "catgirl", "catmaid"):
(wildcards_dir / f"{name}.txt").write_text("x\n", encoding="utf-8")
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()
(wildcards_dir / "flower.txt").write_text("rose\n__color__ lily\n", encoding="utf-8")
(wildcards_dir / "color.txt").write_text("red\nblue\n", encoding="utf-8")
expanded = service.expand_text("__flower__", seed=7)
assert expanded in {"rose", "red lily", "blue lily"}
assert "__" not in expanded
def test_expand_text_resolves_dynamic_prompt_and_multi_select(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
expanded = service.expand_text("{2$$, $$red|blue|green}", seed=3)
assert expanded.count(", ") == 1
assert set(expanded.split(", ")).issubset({"red", "blue", "green"})
def test_expand_text_resolves_wildcard_glob(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "animals").mkdir()
(wildcards_dir / "animals" / "cat.txt").write_text("tabby\n", encoding="utf-8")
(wildcards_dir / "animals" / "dog.txt").write_text("retriever\n", encoding="utf-8")
expanded = service.expand_text("__animals/*__", seed=1)
assert expanded in {"tabby", "retriever"}
def test_expand_text_is_deterministic_with_seed(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
(wildcards_dir / "color.txt").write_text("red\nblue\ngreen\n", encoding="utf-8")
first = service.expand_text("__color__", seed=123)
second = service.expand_text("__color__", seed=123)
assert first == second
def test_expand_text_leaves_unresolved_reference_visible(monkeypatch, tmp_path):
service, wildcards_dir = _make_service(monkeypatch, tmp_path)
wildcards_dir.mkdir()
assert service.expand_text("__missing__", seed=1) == "__missing__"
def test_contains_dynamic_syntax_detects_wildcards_and_options():
assert contains_dynamic_syntax("plain text") is False
assert contains_dynamic_syntax("__flower__") is True
assert contains_dynamic_syntax("{red|blue}") is True
assert contains_dynamic_syntax("{2$$, $$red|blue|green}") is True

View File

@@ -94,6 +94,19 @@ class TestCustomWordsService:
results = service.search_words("test") results = service.search_words("test")
assert mock_tag_index.called assert mock_tag_index.called
def test_search_words_skips_prompt_like_queries(self):
service = CustomWordsService.__new__(CustomWordsService)
mock_tag_index = MockTagFTSIndex()
def mock_get_index():
return mock_tag_index
service._get_tag_index = mock_get_index
results = service.search_words("__flower__ /character f")
assert results == []
assert mock_tag_index.called is False
class MockTagFTSIndex: class MockTagFTSIndex:
"""Mock TagFTSIndex for testing.""" """Mock TagFTSIndex for testing."""

View File

@@ -1,6 +1,7 @@
"""Tests for TagFTSIndex functionality.""" """Tests for TagFTSIndex functionality."""
import os import os
import sqlite3
import tempfile import tempfile
from typing import List from typing import List
@@ -173,6 +174,40 @@ class TestTagFTSIndexSearch:
assert len(results) >= 1 assert len(results) >= 1
assert all(r["category"] in [4, 11] for r in results) assert all(r["category"] in [4, 11] for r in results)
def test_search_with_category_filter_uses_fts_first_plan(self, populated_fts):
"""Category-filtered searches should start from FTS hits, not category scans."""
sql, params = populated_fts._build_search_statement(
query_lower="f",
fts_query="f*",
categories=[4, 11],
limit=20,
offset=0,
)
conn = sqlite3.connect(f"file:{populated_fts.get_database_path()}?mode=ro", uri=True)
try:
plan_rows = conn.execute(f"EXPLAIN QUERY PLAN {sql}", params).fetchall()
finally:
conn.close()
plan_details = [row[3] for row in plan_rows]
assert any(detail.startswith("SCAN tag_fts VIRTUAL TABLE INDEX") for detail in plan_details)
assert any("SEARCH t USING INTEGER PRIMARY KEY" in detail for detail in plan_details)
assert not any("SEARCH t USING INDEX idx_tags_category" in detail for detail in plan_details)
def test_search_statement_uses_post_count_as_tie_breaker(self, populated_fts):
"""Search ranking should use popularity as a secondary sort key."""
sql, _ = populated_fts._build_search_statement(
query_lower="f",
fts_query="f*",
categories=[4, 11],
limit=20,
offset=0,
)
assert "ORDER BY is_tag_name_match DESC, t.post_count DESC, rank_score DESC" in sql
assert "LOG10" not in sql
def test_search_with_category_filter_excludes_others(self, populated_fts): def test_search_with_category_filter_excludes_others(self, populated_fts):
"""Test that category filter excludes other categories.""" """Test that category filter excludes other categories."""
# Search for "hi" but only in general category # Search for "hi" but only in general category

View File

@@ -425,7 +425,7 @@ function shouldBypassAutocompleteWidgetMigration(
} }
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) => const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
widgetNames.has(input.name) || input.forceInput widgetNames.has(input.name)
) )
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) => const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>

View File

@@ -1,6 +1,15 @@
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js"; import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
import {
WILDCARD_COMMANDS,
createWildcardEmptyStateItem,
createWildcardNoMatchesItem,
getWildcardInsertText,
getWildcardSearchEndpoint,
isWildcardCommand,
isWildcardInfoItem,
} from "./autocomplete_wildcards.js";
import { import {
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
getAutocompleteAutoFormatPreference, getAutocompleteAutoFormatPreference,
@@ -13,7 +22,6 @@ import { showToast } from "./utils.js";
// Command definitions for category filtering // Command definitions for category filtering
const TAG_COMMANDS = { const TAG_COMMANDS = {
'/character': { categories: [4, 11], label: 'Character' }, '/character': { categories: [4, 11], label: 'Character' },
'/char': { categories: [4, 11], label: 'Character' },
'/artist': { categories: [1, 8], label: 'Artist' }, '/artist': { categories: [1, 8], label: 'Artist' },
'/general': { categories: [0, 7], label: 'General' }, '/general': { categories: [0, 7], label: 'General' },
'/copyright': { categories: [3, 10], label: 'Copyright' }, '/copyright': { categories: [3, 10], label: 'Copyright' },
@@ -22,6 +30,7 @@ const TAG_COMMANDS = {
'/lore': { categories: [15], label: 'Lore' }, '/lore': { categories: [15], label: 'Lore' },
'/emb': { type: 'embedding', label: 'Embeddings' }, '/emb': { type: 'embedding', label: 'Embeddings' },
'/embedding': { type: 'embedding', label: 'Embeddings' }, '/embedding': { type: 'embedding', label: 'Embeddings' },
...WILDCARD_COMMANDS,
// Autocomplete toggle commands - only show one based on current state // Autocomplete toggle commands - only show one based on current state
'/ac': { '/ac': {
type: 'toggle_setting', type: 'toggle_setting',
@@ -314,6 +323,8 @@ const MODEL_BEHAVIORS = {
const trimmedName = removeGeneralExtension(fileName); const trimmedName = removeGeneralExtension(fileName);
const folder = directories.length ? `${directories.join('/')}/` : ''; const folder = directories.length ? `${directories.join('/')}/` : '';
return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`); return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
} else if (instance.searchType === 'wildcards' || isWildcardCommand(instance.activeCommand)) {
return formatAutocompleteInsertion(getWildcardInsertText(relativePath));
} else { } else {
let tagText = relativePath; let tagText = relativePath;
@@ -350,13 +361,16 @@ class AutoComplete {
this.dropdown = null; this.dropdown = null;
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.items = []; this.items = [];
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;
this.suppressAutocompleteOnce = false;
// Virtual scrolling state // Virtual scrolling state
this.virtualScrollOffset = 0; this.virtualScrollOffset = 0;
@@ -496,6 +510,11 @@ class AutoComplete {
bindEvents() { bindEvents() {
// Handle input changes // Handle input changes
this.onInput = (e) => { this.onInput = (e) => {
if (this.suppressAutocompleteOnce) {
this.suppressAutocompleteOnce = false;
this.hide();
return;
}
this.handleInput(e.target.value); this.handleInput(e.target.value);
}; };
this.inputElement.addEventListener('input', this.onInput); this.inputElement.addEventListener('input', this.onInput);
@@ -512,6 +531,7 @@ class AutoComplete {
const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value); const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value);
if (formattedValue !== this.inputElement.value) { if (formattedValue !== this.inputElement.value) {
this.inputElement.value = formattedValue; this.inputElement.value = formattedValue;
this.suppressAutocompleteOnce = true;
this.inputElement.dispatchEvent(new Event('input', { bubbles: true })); this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
} }
} }
@@ -658,6 +678,9 @@ class AutoComplete {
// /emb or /embedding command // /emb or /embedding command
endpoint = '/lm/embeddings/relative-paths'; endpoint = '/lm/embeddings/relative-paths';
this.searchType = 'embeddings'; this.searchType = 'embeddings';
} else if (isWildcardCommand(commandResult.command)) {
endpoint = getWildcardSearchEndpoint();
this.searchType = 'wildcards';
} else { } else {
// Category filter command // Category filter command
const categories = commandResult.command.categories.join(','); const categories = commandResult.command.categories.join(',');
@@ -684,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;
} }
@@ -713,9 +741,24 @@ class AutoComplete {
} }
const rawText = beforeCursor.substring(start); const rawText = beforeCursor.substring(start);
const text = rawText.trim();
const leadingWhitespaceLength = rawText.length - rawText.trimStart().length; const leadingWhitespaceLength = rawText.length - rawText.trimStart().length;
const trimmedStart = start + leadingWhitespaceLength; const trimmedStart = start + leadingWhitespaceLength;
const text = rawText.trim();
if (this.modelType === 'prompt') {
const tokenRange = this._getPromptTokenRange(rawText, trimmedStart, caretPos);
if (tokenRange) {
return {
start: tokenRange.start,
trimmedStart: tokenRange.trimmedStart,
end: caretPos,
beforeCursor,
rawText: tokenRange.rawText,
text: tokenRange.text,
tokenType: tokenRange.tokenType,
};
}
}
return { return {
start, start,
@@ -727,6 +770,73 @@ class AutoComplete {
}; };
} }
_getPromptTokenRange(rawText = '', trimmedStart = 0, caretPos = 0) {
const trimmedText = rawText.trim();
if (!trimmedText) {
return {
start: trimmedStart,
trimmedStart,
rawText: '',
text: '',
tokenType: 'empty',
};
}
const commandOffset = trimmedText.startsWith('/')
? 0
: trimmedText.lastIndexOf(' /');
if (commandOffset !== -1) {
const normalizedCommandOffset = commandOffset === 0 ? 0 : commandOffset + 1;
const commandText = trimmedText.slice(normalizedCommandOffset);
const commandStart = trimmedStart + normalizedCommandOffset;
return {
start: commandStart,
trimmedStart: commandStart,
rawText: commandText,
text: commandText,
tokenType: commandText === '/' ? 'empty_command_trigger' : 'command',
};
}
const wildcardMatch = trimmedText.match(/(?:^|\s)(__[\w\s.\-+/*\\]+?__)$/);
if (wildcardMatch) {
const wildcardText = wildcardMatch[1];
const wildcardOffset = trimmedText.lastIndexOf(wildcardText);
const wildcardStart = trimmedStart + wildcardOffset;
return {
start: wildcardStart,
trimmedStart: wildcardStart,
rawText: wildcardText,
text: '',
tokenType: 'wildcard_literal',
};
}
const embeddingOffset = trimmedText.search(/(?:^|\s)emb:[^\s]*$/i);
if (embeddingOffset !== -1) {
const normalizedEmbeddingOffset = trimmedText.slice(embeddingOffset).startsWith(' ')
? embeddingOffset + 1
: embeddingOffset;
const embeddingText = trimmedText.slice(normalizedEmbeddingOffset);
const embeddingStart = trimmedStart + normalizedEmbeddingOffset;
return {
start: embeddingStart,
trimmedStart: embeddingStart,
rawText: embeddingText,
text: embeddingText,
tokenType: 'embedding_literal',
};
}
return {
start: trimmedStart,
trimmedStart,
rawText,
text: trimmedText,
tokenType: 'tag_text',
};
}
_getHardBoundaryStart(beforeCursor = '') { _getHardBoundaryStart(beforeCursor = '') {
const lastComma = beforeCursor.lastIndexOf(','); const lastComma = beforeCursor.lastIndexOf(',');
const lastAngle = beforeCursor.lastIndexOf('>'); const lastAngle = beforeCursor.lastIndexOf('>');
@@ -878,12 +988,50 @@ class AutoComplete {
return Array.from(variations).filter(v => v.length >= this.options.minChars); return Array.from(variations).filter(v => v.length >= this.options.minChars);
} }
_normalizeQueryForRequest(term = '') {
return term.trim().toLowerCase();
}
_getQueriesToExecute(term = '') {
const queryVariations = this._generateQueryVariations(term);
const uniqueQueries = [];
const seen = new Set();
for (const query of queryVariations) {
const normalized = this._normalizeQueryForRequest(query);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
uniqueQueries.push(query);
if (uniqueQueries.length >= 4) {
break;
}
}
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') {
@@ -1013,6 +1161,14 @@ class AutoComplete {
return 0; return 0;
} }
_getAcceptSelectionIndex(searchTerm = '') {
if (this.hasManualSelection && this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
return this.selectedIndex;
}
return this._getPreferredSelectedIndex(searchTerm);
}
async search(term = '', endpoint = null) { async search(term = '', endpoint = null) {
try { try {
this.currentSearchTerm = term; this.currentSearchTerm = term;
@@ -1024,23 +1180,26 @@ 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`;
} }
// Generate multiple query variations for better matching // Generate multiple query variations for better matching, but avoid
const queryVariations = this._generateQueryVariations(term); // sending duplicate-equivalent requests that normalize to the same
// backend search term.
const queriesToExecute =
this.searchType === 'wildcards' && term.length === 0
? ['']
: this._getQueriesToExecute(term);
if (queryVariations.length === 0) { if (queriesToExecute.length === 0) {
this.items = []; this.items = [];
this.hide(); this.hide();
return; return;
} }
// Limit the number of parallel queries to avoid overwhelming the server
const queriesToExecute = queryVariations.slice(0, 4);
// Execute all queries in parallel // Execute all queries in parallel
const searchPromises = queriesToExecute.map(async (query) => { const searchPromises = queriesToExecute.map(async (query) => {
const url = endpoint.includes('?') const url = endpoint.includes('?')
@@ -1050,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,
};
} }
}); });
@@ -1071,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()
@@ -1084,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) {
@@ -1144,7 +1325,7 @@ class AutoComplete {
}; };
} }
// Command with search term (e.g., "/char miku") // Command with search term (e.g., "/character miku")
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase(); const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
const searchPart = trimmed.slice(spaceIndex + 1).trim(); const searchPart = trimmed.slice(spaceIndex + 1).trim();
@@ -1178,20 +1359,15 @@ class AutoComplete {
const filterLower = filter.toLowerCase(); const filterLower = filter.toLowerCase();
// Get unique commands (avoid duplicates like /char and /character)
const seenLabels = new Set();
const commands = []; const commands = [];
for (const [cmd, info] of Object.entries(TAG_COMMANDS)) { for (const [cmd, info] of Object.entries(TAG_COMMANDS)) {
if (seenLabels.has(info.label)) continue;
// Filter out toggle commands that don't meet their condition // Filter out toggle commands that don't meet their condition
if (info.type === 'toggle_setting' && info.condition) { if (info.type === 'toggle_setting' && info.condition) {
if (!info.condition()) continue; if (!info.condition()) continue;
} }
if (!filter || cmd.slice(1).startsWith(filterLower)) { if (!filter || cmd.slice(1).startsWith(filterLower)) {
seenLabels.add(info.label);
commands.push({ command: cmd, ...info }); commands.push({ command: cmd, ...info });
} }
} }
@@ -1219,6 +1395,7 @@ class AutoComplete {
this.dropdown.innerHTML = ''; this.dropdown.innerHTML = '';
} }
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.items.forEach((item, index) => { this.items.forEach((item, index) => {
const itemEl = document.createElement('div'); const itemEl = document.createElement('div');
@@ -1254,7 +1431,7 @@ class AutoComplete {
`; `;
itemEl.addEventListener('mouseenter', () => { itemEl.addEventListener('mouseenter', () => {
this.selectItem(index); this.selectItem(index, { manual: true });
}); });
itemEl.addEventListener('click', () => { itemEl.addEventListener('click', () => {
@@ -1276,8 +1453,17 @@ class AutoComplete {
} }
// Auto-select immediately so accept keys remain stable. // Auto-select immediately so accept keys remain stable.
// In virtual-scroll mode, calling selectItem() before the dropdown is
// visible can see a zero-height container and incorrectly replace the
// full command list with a partially virtualized slice.
if (this.items.length > 0) { if (this.items.length > 0) {
this.selectItem(0); this.selectedIndex = 0;
this.hasManualSelection = false;
if (this.contentContainer) {
this._applyItemSelection(0);
} else {
this.selectItem(0);
}
} }
// Update virtual scroll height for virtual scrolling mode // Update virtual scroll height for virtual scrolling mode
@@ -1288,7 +1474,7 @@ class AutoComplete {
/** /**
* Insert a command into the input * Insert a command into the input
* @param {string} command - The command to insert (e.g., "/char") * @param {string} command - The command to insert (e.g., "/character")
*/ */
_insertCommand(command) { _insertCommand(command) {
const currentValue = this.inputElement.value; const currentValue = this.inputElement.value;
@@ -1315,6 +1501,7 @@ class AutoComplete {
render() { render() {
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
// Reset virtual scroll state // Reset virtual scroll state
this.virtualScrollOffset = 0; this.virtualScrollOffset = 0;
@@ -1346,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);
});
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);
}); });
@@ -1524,6 +1627,124 @@ class AutoComplete {
itemEl.appendChild(nameSpan); itemEl.appendChild(nameSpan);
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);
@@ -1541,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>', '<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) { showPreviewForItem(relativePath, itemElement) {
if (!this.options.showPreview || !this.previewTooltip) return; if (!this.options.showPreview || !this.previewTooltip) return;
@@ -1611,6 +1888,8 @@ class AutoComplete {
if (this.modelType === 'prompt') { if (this.modelType === 'prompt') {
if (this.searchType === 'embeddings') { if (this.searchType === 'embeddings') {
endpoint = '/lm/embeddings/relative-paths'; endpoint = '/lm/embeddings/relative-paths';
} else if (this.searchType === 'wildcards') {
endpoint = getWildcardSearchEndpoint();
} else if (this.searchType === 'custom_words') { } else if (this.searchType === 'custom_words') {
if (this.activeCommand?.categories) { if (this.activeCommand?.categories) {
const categories = this.activeCommand.categories.join(','); const categories = this.activeCommand.categories.join(',');
@@ -1621,8 +1900,7 @@ class AutoComplete {
} }
} }
const queryVariations = this._generateQueryVariations(this.currentSearchTerm); const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
const queriesToExecute = queryVariations.slice(0, 4);
const offset = this.items.length; const offset = this.items.length;
// Execute all queries in parallel with offset // Execute all queries in parallel with offset
@@ -1733,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`;
@@ -1751,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;
@@ -1830,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';
@@ -1862,7 +2160,7 @@ class AutoComplete {
// Hover and selection handlers // Hover and selection handlers
item.addEventListener('mouseenter', () => { item.addEventListener('mouseenter', () => {
this.selectItem(index); this.selectItem(index, { manual: true });
}); });
item.addEventListener('mouseleave', () => { item.addEventListener('mouseleave', () => {
@@ -1871,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 {
@@ -1954,11 +2256,13 @@ class AutoComplete {
this.dropdown.style.display = 'none'; this.dropdown.style.display = 'none';
this.isVisible = false; this.isVisible = false;
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.showingCommands = false; this.showingCommands = false;
// 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) {
@@ -1992,7 +2296,7 @@ class AutoComplete {
}); });
} }
selectItem(index) { selectItem(index, { manual = false } = {}) {
// Remove previous selection // Remove previous selection
const container = this.options.enableVirtualScroll && this.contentContainer const container = this.options.enableVirtualScroll && this.contentContainer
? this.contentContainer ? this.contentContainer
@@ -2006,6 +2310,7 @@ class AutoComplete {
// Add new selection // Add new selection
if (index >= 0 && index < this.items.length) { if (index >= 0 && index < this.items.length) {
this.selectedIndex = index; this.selectedIndex = index;
this.hasManualSelection = manual;
// For virtual scrolling, we need to ensure the item is rendered // For virtual scrolling, we need to ensure the item is rendered
if (this.options.enableVirtualScroll && this.scrollContainer) { if (this.options.enableVirtualScroll && this.scrollContainer) {
@@ -2046,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) {
@@ -2073,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) {
@@ -2099,15 +2404,15 @@ class AutoComplete {
this.loadMoreItems().then(() => { this.loadMoreItems().then(() => {
// After loading more, select the next item // After loading more, select the next item
if (this.selectedIndex < this.items.length - 1) { if (this.selectedIndex < this.items.length - 1) {
this.selectItem(this.selectedIndex + 1); this.selectItem(this.selectedIndex + 1, { manual: true });
} }
}); });
} }
} else { } else {
this.selectItem(this.selectedIndex + 1); this.selectItem(this.selectedIndex + 1, { manual: true });
} }
} else { } else {
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1)); this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1), { manual: true });
} }
break; break;
@@ -2117,12 +2422,12 @@ class AutoComplete {
// For virtual scrolling, handle top boundary // For virtual scrolling, handle top boundary
if (this.selectedIndex <= 0) { if (this.selectedIndex <= 0) {
// Already at first item, ensure it's selected // Already at first item, ensure it's selected
this.selectItem(0); this.selectItem(0, { manual: true });
} else { } else {
this.selectItem(this.selectedIndex - 1); this.selectItem(this.selectedIndex - 1, { manual: true });
} }
} else { } else {
this.selectItem(Math.max(this.selectedIndex - 1, 0)); this.selectItem(Math.max(this.selectedIndex - 1, 0), { manual: true });
} }
break; break;
@@ -2134,9 +2439,9 @@ class AutoComplete {
{ {
const liveSearchTerm = this._getLiveSearchTermForAcceptance(); const liveSearchTerm = this._getLiveSearchTermForAcceptance();
const preferredIndex = this._getPreferredSelectedIndex(liveSearchTerm); const acceptIndex = this._getAcceptSelectionIndex(liveSearchTerm);
if (preferredIndex !== -1 && preferredIndex !== this.selectedIndex) { if (acceptIndex !== -1 && acceptIndex !== this.selectedIndex) {
this.selectItem(preferredIndex); this.selectItem(acceptIndex);
} }
} }
@@ -2146,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;
@@ -2180,7 +2492,7 @@ class AutoComplete {
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, " // This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
// However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side" // However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side"
// matching "looking_to_the_side"), replace the entire phrase instead of just the last word. // matching "looking_to_the_side"), replace the entire phrase instead of just the last word.
// Command mode (e.g., "/char miku") should replace the entire command+search // Command mode (e.g., "/character miku") should replace the entire command+search
let searchTerm = fullSearchTerm; let searchTerm = fullSearchTerm;
if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) { if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
// Check if the selectedItem exists and its tag_name matches the full search term // Check if the selectedItem exists and its tag_name matches the full search term

View File

@@ -0,0 +1,54 @@
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';
}
export function getWildcardSearchEndpoint() {
return '/lm/wildcards/search';
}
export function getWildcardInsertText(relativePath = '') {
const trimmed = typeof relativePath === 'string' ? relativePath.trim() : '';
if (!trimmed) {
return '';
}
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 : [],
};
}

View File

@@ -15557,7 +15557,7 @@ function shouldBypassAutocompleteWidgetMigration(node, widgetValues) {
return false; return false;
} }
const originalWidgetsInputs = Object.values(inputDefs).filter( const originalWidgetsInputs = Object.values(inputDefs).filter(
(input) => widgetNames.has(input.name) || input.forceInput (input) => widgetNames.has(input.name)
); );
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap( const widgetIndexHasForceInput = originalWidgetsInputs.flatMap(
(input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput] (input) => input.control_after_generate ? [!!input.forceInput, false] : [!!input.forceInput]

File diff suppressed because one or more lines are too long