mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-05-07 00:46:44 -03:00
Compare commits
10 Commits
6d0d9600a7
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4fa1631ee | ||
|
|
506d763dc2 | ||
|
|
a2cd09b619 | ||
|
|
cdd77029b6 | ||
|
|
439679e15f | ||
|
|
2640258902 | ||
|
|
b910388d54 | ||
|
|
083de395b1 | ||
|
|
4514ca94b7 | ||
|
|
62247bdd87 |
46
README.md
46
README.md
@@ -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
@@ -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,18 +94,37 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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),)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
428
py/services/wildcard_service.py
Normal file
428
py/services/wildcard_service.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ GitPython
|
|||||||
aiosqlite
|
aiosqlite
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
platformdirs
|
platformdirs
|
||||||
|
pyyaml
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
84
tests/nodes/test_prompt_text_wildcards.py
Normal file
84
tests/nodes/test_prompt_text_wildcards.py
Normal 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
|
||||||
@@ -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 = []
|
||||||
|
|||||||
69
tests/routes/test_wildcard_routes.py
Normal file
69
tests/routes/test_wildcard_routes.py
Normal 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"
|
||||||
141
tests/services/test_wildcard_service.py
Normal file
141
tests/services/test_wildcard_service.py
Normal 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
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,9 +1453,18 @@ 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.selectedIndex = 0;
|
||||||
|
this.hasManualSelection = false;
|
||||||
|
if (this.contentContainer) {
|
||||||
|
this._applyItemSelection(0);
|
||||||
|
} else {
|
||||||
this.selectItem(0);
|
this.selectItem(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update virtual scroll height for virtual scrolling mode
|
// Update virtual scroll height for virtual scrolling mode
|
||||||
if (this.contentContainer) {
|
if (this.contentContainer) {
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1525,6 +1628,124 @@ class AutoComplete {
|
|||||||
itemEl.appendChild(metaSpan);
|
itemEl.appendChild(metaSpan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderInformationalItem(itemEl, itemData) {
|
||||||
|
itemEl.classList.add('comfy-autocomplete-info-item');
|
||||||
|
itemEl.style.cssText = `
|
||||||
|
padding: 12px;
|
||||||
|
color: rgba(226, 232, 240, 0.88);
|
||||||
|
border-bottom: none;
|
||||||
|
cursor: default;
|
||||||
|
display: block;
|
||||||
|
white-space: normal;
|
||||||
|
height: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'lm-autocomplete-info-title';
|
||||||
|
title.textContent = itemData.title || 'Wildcards';
|
||||||
|
title.style.cssText = `
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
`;
|
||||||
|
itemEl.appendChild(title);
|
||||||
|
|
||||||
|
const description = document.createElement('div');
|
||||||
|
description.className = 'lm-autocomplete-info-description';
|
||||||
|
description.textContent = itemData.description || '';
|
||||||
|
description.style.cssText = `
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: rgba(226, 232, 240, 0.72);
|
||||||
|
`;
|
||||||
|
itemEl.appendChild(description);
|
||||||
|
|
||||||
|
if (itemData.type === 'wildcard_no_matches') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathBlock = document.createElement('div');
|
||||||
|
pathBlock.style.cssText = `
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
`;
|
||||||
|
pathBlock.innerHTML = [
|
||||||
|
'<div style="font-weight: 600; margin-bottom: 4px;">Wildcards folder</div>',
|
||||||
|
`<code style="word-break: break-all; color: #dbeafe;">${itemData.wildcardsDir || '(unavailable)'}</code>`,
|
||||||
|
`<div style="margin-top: 6px; color: rgba(226, 232, 240, 0.68);">Supported formats: ${(itemData.supportedFormats || []).join(', ')}</div>`,
|
||||||
|
].join('');
|
||||||
|
itemEl.appendChild(pathBlock);
|
||||||
|
|
||||||
|
const examples = document.createElement('div');
|
||||||
|
examples.style.cssText = `
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: rgba(226, 232, 240, 0.72);
|
||||||
|
`;
|
||||||
|
examples.innerHTML = [
|
||||||
|
'<div style="font-weight: 600; color: rgba(226, 232, 240, 0.88); margin-bottom: 4px;">Examples</div>',
|
||||||
|
'<div><code>animals/cat.txt</code> -> use <code>__animals/cat__</code></div>',
|
||||||
|
'<div><code>colors.yaml</code> with <code>palette: { warm: [red, orange] }</code> -> use <code>__palette/warm__</code></div>',
|
||||||
|
'<div style="margin-top: 6px;">Text files use one option per line. YAML/JSON use nested keys ending in string arrays.</div>',
|
||||||
|
].join('');
|
||||||
|
itemEl.appendChild(examples);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const openButton = document.createElement('button');
|
||||||
|
openButton.type = 'button';
|
||||||
|
openButton.dataset.action = 'open-wildcards-folder';
|
||||||
|
openButton.textContent = 'Open wildcards folder';
|
||||||
|
openButton.style.cssText = `
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.45);
|
||||||
|
background: rgba(37, 99, 235, 0.18);
|
||||||
|
color: #dbeafe;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
openButton.addEventListener('click', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
await this._openWildcardsFolder();
|
||||||
|
});
|
||||||
|
actions.appendChild(openButton);
|
||||||
|
|
||||||
|
const copyButton = document.createElement('button');
|
||||||
|
copyButton.type = 'button';
|
||||||
|
copyButton.dataset.action = 'copy-wildcards-path';
|
||||||
|
copyButton.textContent = 'Copy path';
|
||||||
|
copyButton.style.cssText = `
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
color: rgba(226, 232, 240, 0.88);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
copyButton.addEventListener('click', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
await this._copyWildcardPath(itemData.wildcardsDir || '');
|
||||||
|
});
|
||||||
|
actions.appendChild(copyButton);
|
||||||
|
|
||||||
|
itemEl.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
highlightMatch(text, searchTerm) {
|
highlightMatch(text, searchTerm) {
|
||||||
const { include } = parseSearchTokens(searchTerm);
|
const { include } = parseSearchTokens(searchTerm);
|
||||||
const sanitizedTokens = include
|
const sanitizedTokens = include
|
||||||
@@ -1542,6 +1763,62 @@ class AutoComplete {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _openWildcardsFolder() {
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi('/lm/wildcards/open-location', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok || data?.success === false) {
|
||||||
|
throw new Error(data?.error || 'Failed to open wildcards folder');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.mode === 'clipboard' && data?.path) {
|
||||||
|
await this._copyWildcardPath(data.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Wildcards folder',
|
||||||
|
detail: 'Opened wildcards folder.',
|
||||||
|
life: 2500,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Lora Manager] Failed to open wildcards folder:', error);
|
||||||
|
showToast({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error?.message || 'Failed to open wildcards folder',
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _copyWildcardPath(path) {
|
||||||
|
if (!path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(path);
|
||||||
|
}
|
||||||
|
showToast({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Wildcards path',
|
||||||
|
detail: path,
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Lora Manager] Failed to copy wildcards path:', error);
|
||||||
|
showToast({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Wildcards path',
|
||||||
|
detail: path,
|
||||||
|
life: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showPreviewForItem(relativePath, itemElement) {
|
showPreviewForItem(relativePath, itemElement) {
|
||||||
if (!this.options.showPreview || !this.previewTooltip) return;
|
if (!this.options.showPreview || !this.previewTooltip) return;
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
54
web/comfyui/autocomplete_wildcards.js
Normal file
54
web/comfyui/autocomplete_wildcards.js
Normal 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 : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user