Add CharacterPrompts nodes and web UI

Introduce a new CharacterPrompts extension: adds node implementations (Character Create, Character Edit/Delete, Character Prompt Encode) with storage, resolver, and README. Adds persistent character library storage (default/ComfyUI-CharacterPrompts-MXD/characters.json), server route (/mxd/characters/list), and a web UI integration (mxd_character_prompts.js + import) to refresh/pick characters in the graph. Update root __init__ to register the new module. Also remove the Grow Blur Mask MXD node from maxedoutnodes, and bump package version to 1.9.0 in pyproject.toml.
This commit is contained in:
Maxed-Out-99
2026-02-27 08:42:22 -08:00
parent f7c8bbcc75
commit e35ec29f4f
10 changed files with 827 additions and 92 deletions
+56
View File
@@ -0,0 +1,56 @@
# ComfyUI-CharacterPrompts-MXD
Beginner-friendly character alias nodes for ComfyUI.
## What This Adds
1. `Character Create`
- Create a new character or update an existing one by name.
2. `Character Edit/Delete`
- Pick from a dropdown of saved characters.
- Includes a refresh button on the dropdown.
- Load selected character data, save prompt changes, or delete selected.
3. `Character Prompt Encode`
- Resolves character names in your prompt (`Homayun`) and tags (`@Homayun`).
- Replaces references with the saved character description text.
- Outputs `CONDITIONING` directly for sampler wiring.
- Supports multi-character handling modes for cleaner multi-character prompts.
## Storage Location
Character library is shared globally at:
`<ComfyUI user>/default/ComfyUI-CharacterPrompts-MXD/characters.json`
Example:
`ComfyUI/user/default/ComfyUI-CharacterPrompts-MXD/characters.json`
## Quick Start
1. Add `Character Create`.
2. Fill:
- `character_name`: `Homayun`
- `character_prompt`: your full character description
3. Queue once to save.
4. Optional edits/deletes:
- Add `Character Edit/Delete`.
- Pick character from dropdown.
- Use refresh button if needed.
5. Add `Character Prompt Encode`.
6. Connect your `CLIP` input.
7. Write a normal scene prompt:
- `Homayun holding flowers`
- or `@Homayun holding flowers`
8. Use `conditioning` output as your positive conditioning.
## Recommended Settings
- `character_handling`: `separate_with_break`
## Notes
- When 2+ characters are detected and `character_handling` is `separate_with_break` or `combine_conditioning`, each character description is encoded as a separate segment before scene text.
- If fewer than 2 characters are detected, it falls back to a single resolved prompt encode.
+3
View File
@@ -0,0 +1,3 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+244
View File
@@ -0,0 +1,244 @@
import logging
import torch
from . import resolver, storage
try:
from aiohttp import web
from server import PromptServer
HAVE_SERVER = True
except Exception:
web = None
PromptServer = None
HAVE_SERVER = False
CHARACTER_LIST_ROUTE = "/mxd/characters/list"
NO_CHARACTERS_OPTION = "(no characters saved)"
def _list_character_names() -> list[str]:
schema = storage.load_schema()
return storage.list_character_names(schema)
def _selected_character_input():
names = _list_character_names()
options = names if names else [NO_CHARACTERS_OPTION]
return (
options,
{
"default": options[0],
"tooltip": "Choose a saved character. Use the refresh button to update this list.",
},
)
def _is_valid_selected(name: str) -> bool:
return bool(name and name != NO_CHARACTERS_OPTION)
if HAVE_SERVER:
routes = PromptServer.instance.routes
@routes.get(CHARACTER_LIST_ROUTE)
async def mxd_character_prompt_list(_request):
names = _list_character_names()
if not names:
names = [NO_CHARACTERS_OPTION]
return web.json_response(names)
def _encode_text(clip, text: str):
tokens = clip.tokenize(text)
return clip.encode_from_tokens_scheduled(tokens)
def _conditioning_concat(conditioning_to, conditioning_from):
out = []
if len(conditioning_from) > 1:
logging.warning(
"MXD Character Prompt Encode: concat source contains more than one condition; using first condition only."
)
cond_from = conditioning_from[0][0]
for i in range(len(conditioning_to)):
cond_to = conditioning_to[i][0]
merged = torch.cat((cond_to, cond_from), 1)
out.append([merged, conditioning_to[i][1].copy()])
return out
def _conditioning_combine(conditioning_1, conditioning_2):
return conditioning_1 + conditioning_2
def _encode_segments(clip, segments: list[str], break_mode: str):
if not segments:
return _encode_text(clip, "")
conditioning = _encode_text(clip, segments[0])
for segment in segments[1:]:
segment_cond = _encode_text(clip, segment)
if break_mode == "concat_characters_then_scene":
conditioning = _conditioning_concat(conditioning, segment_cond)
elif break_mode == "combine_characters_then_scene":
conditioning = _conditioning_combine(conditioning, segment_cond)
return conditioning
class MXDCharacterCreate:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"character_name": ("STRING", {"default": "", "multiline": False}),
"character_prompt": ("STRING", {"default": "", "multiline": True}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status",)
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "MXD/Character"
TITLE = "Character Create"
def run(self, character_name, character_prompt):
try:
canonical = storage.normalize_key(character_name)
existing_schema = storage.load_schema()
existed = canonical in existing_schema["characters"]
schema, entry = storage.save_or_update_character(
character_name=character_name,
character_prompt=character_prompt,
aliases_csv=None,
)
count = len(schema["characters"])
action = "Updated" if existed else "Created"
status = f"{action} character '{entry['name']}' ({count} total)."
return (status,)
except Exception as error:
return (f"Error: {error}",)
class MXDCharacterEditDelete:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"selected_character": _selected_character_input(),
"action": (
["load_character", "edit_character", "delete_character"],
{"default": "load_character"},
),
"character_prompt": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Used when action=edit_character.",
},
),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("character_prompt",)
FUNCTION = "run"
OUTPUT_NODE = True
CATEGORY = "MXD/Character"
TITLE = "Character Edit/Delete"
def run(self, selected_character, action, character_prompt):
try:
if not _is_valid_selected(selected_character):
return ("",)
if action == "load_character":
_schema, _canonical, entry = storage.load_character(selected_character)
return (entry["prompt"],)
if action == "edit_character":
_, _canonical, current_entry = storage.load_character(selected_character)
_schema, updated_entry = storage.save_or_update_character(
character_name=current_entry["name"],
character_prompt=character_prompt,
aliases_csv=None,
)
return (updated_entry["prompt"],)
storage.delete_character(selected_character)
return ("",)
except Exception:
return ("",)
class MXDCharacterPromptEncode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"clip": ("CLIP",),
"prompt": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": True}),
"character_handling": (
["separate_with_break", "combine_conditioning", "single_prompt"],
{"default": "separate_with_break"},
),
}
}
RETURN_TYPES = ("CONDITIONING", "STRING")
RETURN_NAMES = ("conditioning", "prompt_with_character_text")
FUNCTION = "encode"
CATEGORY = "MXD/Character"
TITLE = "Character Prompt Encode"
def encode(self, clip, prompt, character_handling):
if clip is None:
raise RuntimeError(
"ERROR: clip input is invalid: None\n\nIf the clip is from a checkpoint loader node your checkpoint does not contain a valid clip or text encoder model."
)
schema = storage.load_schema()
characters = schema["characters"]
resolved = resolver.resolve_prompt(
prompt=prompt,
characters=characters,
reference_mode="plain_only",
missing_behavior="warn_and_keep_text",
)
used_prompts = []
for canonical in resolved.used_canonical_names:
entry = characters.get(canonical)
if entry and entry.get("prompt"):
used_prompts.append(entry["prompt"])
if character_handling != "single_prompt" and len(used_prompts) >= 2:
segments = used_prompts + [resolved.scene_prompt]
if character_handling == "separate_with_break":
break_mode = "concat_characters_then_scene"
else:
break_mode = "combine_characters_then_scene"
conditioning = _encode_segments(clip, segments, break_mode)
else:
conditioning = _encode_text(clip, resolved.resolved_prompt)
return (conditioning, resolved.resolved_prompt)
NODE_CLASS_MAPPINGS = {
"MXDCharacterCreate": MXDCharacterCreate,
"MXDCharacterEditDelete": MXDCharacterEditDelete,
"MXDCharacterPromptEncode": MXDCharacterPromptEncode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MXDCharacterCreate": "Character Create MXD",
"MXDCharacterEditDelete": "Character Edit/Delete MXD",
"MXDCharacterPromptEncode": "Character Prompt Encode MXD",
}
+145
View File
@@ -0,0 +1,145 @@
import logging
import re
from dataclasses import dataclass
from .storage import normalize_key
@dataclass
class ResolveResult:
resolved_prompt: str
scene_prompt: str
used_canonical_names: list[str]
missing_tags: list[str]
def _build_lookup(characters: dict) -> dict[str, str]:
lookup = {}
for canonical, entry in characters.items():
lookup[canonical] = canonical
for alias in entry.get("aliases", []):
alias_key = normalize_key(str(alias))
if alias_key:
lookup[alias_key] = canonical
return lookup
def _add_used(used: list[str], canonical: str) -> None:
if canonical not in used:
used.append(canonical)
def _scene_prompt_from_original(prompt: str) -> str:
# Keep scene wording intact, only drop explicit @ marker.
return re.sub(r"@([A-Za-z0-9_\-]+)", r"\1", prompt)
def _find_missing_tags(prompt: str, lookup: dict[str, str]) -> list[str]:
seen = set()
missing = []
for match in re.finditer(r"@([A-Za-z0-9_\-]+)", prompt):
raw = match.group(1)
key = normalize_key(raw)
if key not in lookup and key not in seen:
missing.append(raw)
seen.add(key)
return missing
def _pattern_from_terms(terms: list[str], prefix: str = "", suffix: str = "") -> re.Pattern | None:
if not terms:
return None
escaped = [re.escape(term) for term in terms]
pattern = f"{prefix}({'|'.join(escaped)}){suffix}"
return re.compile(pattern, re.IGNORECASE)
def _replace_with_placeholders(
text: str,
pattern: re.Pattern | None,
lookup: dict[str, str],
characters: dict,
used: list[str],
placeholders: dict[str, str],
next_id_start: int = 1,
) -> tuple[str, int]:
if pattern is None:
return text, next_id_start
next_id = next_id_start
def callback(match: re.Match) -> str:
nonlocal next_id
matched = normalize_key(match.group(1))
canonical = lookup.get(matched)
if canonical is None:
return match.group(0)
_add_used(used, canonical)
placeholder = f"__MXD_CHAR_{next_id}__"
next_id += 1
placeholders[placeholder] = characters[canonical]["prompt"]
return placeholder
return pattern.sub(callback, text), next_id
def _handle_missing_tags(missing: list[str], missing_behavior: str) -> None:
if not missing:
return
missing_text = ", ".join(missing)
if missing_behavior == "error":
raise ValueError(f"Unknown character tags: {missing_text}")
if missing_behavior == "warn_and_keep_text":
logging.warning("MXD Character Prompt Encode: unknown character tags left unchanged: %s", missing_text)
def resolve_prompt(
prompt: str,
characters: dict,
reference_mode: str = "support_both",
missing_behavior: str = "warn_and_keep_text",
) -> ResolveResult:
lookup = _build_lookup(characters)
used = []
placeholders = {}
text = prompt
next_id = 1
sorted_terms = sorted(lookup.keys(), key=len, reverse=True)
tag_pattern = _pattern_from_terms(sorted_terms, prefix=r"(?<!\w)@", suffix=r"(?![\w\-])")
plain_pattern = _pattern_from_terms(sorted_terms, prefix=r"(?<![@\w])", suffix=r"(?![\w\-])")
if reference_mode in ("support_both", "tags_only"):
text, next_id = _replace_with_placeholders(
text=text,
pattern=tag_pattern,
lookup=lookup,
characters=characters,
used=used,
placeholders=placeholders,
next_id_start=next_id,
)
if reference_mode in ("support_both", "plain_only"):
text, next_id = _replace_with_placeholders(
text=text,
pattern=plain_pattern,
lookup=lookup,
characters=characters,
used=used,
placeholders=placeholders,
next_id_start=next_id,
)
for placeholder, replacement in placeholders.items():
text = text.replace(placeholder, replacement)
missing = _find_missing_tags(prompt, lookup) if reference_mode in ("support_both", "tags_only") else []
_handle_missing_tags(missing, missing_behavior)
return ResolveResult(
resolved_prompt=text,
scene_prompt=_scene_prompt_from_original(prompt),
used_canonical_names=used,
missing_tags=missing,
)
+193
View File
@@ -0,0 +1,193 @@
import json
import os
from datetime import datetime, timezone
import folder_paths
SCHEMA_VERSION = 1
EXTENSION_DIRNAME = "ComfyUI-CharacterPrompts-MXD"
FILENAME = "characters.json"
def normalize_key(value: str) -> str:
return " ".join(value.strip().lower().split())
def parse_aliases_csv(aliases_csv: str) -> list[str]:
if not aliases_csv:
return []
aliases = []
seen = set()
for raw in aliases_csv.split(","):
alias = normalize_key(raw)
if alias and alias not in seen:
aliases.append(alias)
seen.add(alias)
return aliases
def get_library_dir() -> str:
return os.path.join(folder_paths.get_user_directory(), "default", EXTENSION_DIRNAME)
def get_library_path() -> str:
return os.path.join(get_library_dir(), FILENAME)
def _empty_schema() -> dict:
return {"version": SCHEMA_VERSION, "characters": {}}
def _write_json_atomic(path: str, data: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, ensure_ascii=False)
os.replace(tmp_path, path)
def _normalize_entry(canonical: str, entry: dict) -> dict:
name = str(entry.get("name", canonical)).strip() or canonical
prompt = str(entry.get("prompt", "")).strip()
aliases_raw = entry.get("aliases", [])
aliases = []
seen = {canonical}
if isinstance(aliases_raw, list):
for alias_raw in aliases_raw:
alias = normalize_key(str(alias_raw))
if alias and alias not in seen:
aliases.append(alias)
seen.add(alias)
updated_at = str(entry.get("updated_at", _utc_now()))
return {
"name": name,
"prompt": prompt,
"aliases": aliases,
"updated_at": updated_at,
}
def _normalize_schema(raw: dict) -> dict:
if not isinstance(raw, dict):
return _empty_schema()
characters_raw = raw.get("characters", {})
if not isinstance(characters_raw, dict):
characters_raw = {}
normalized = {}
for key, entry in characters_raw.items():
canonical = normalize_key(str(key))
if not canonical or not isinstance(entry, dict):
continue
normalized[canonical] = _normalize_entry(canonical, entry)
return {"version": SCHEMA_VERSION, "characters": normalized}
def _backup_corrupt_file(path: str) -> None:
if not os.path.exists(path):
return
backup_path = f"{path}.bak"
try:
if os.path.exists(backup_path):
os.remove(backup_path)
os.replace(path, backup_path)
except OSError:
pass
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def load_schema() -> dict:
path = get_library_path()
if not os.path.exists(path):
schema = _empty_schema()
_write_json_atomic(path, schema)
return schema
try:
with open(path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
except (OSError, json.JSONDecodeError):
_backup_corrupt_file(path)
schema = _empty_schema()
_write_json_atomic(path, schema)
return schema
schema = _normalize_schema(raw)
if schema != raw:
_write_json_atomic(path, schema)
return schema
def save_schema(schema: dict) -> None:
normalized = _normalize_schema(schema)
_write_json_atomic(get_library_path(), normalized)
def list_character_names(schema: dict | None = None) -> list[str]:
schema = schema or load_schema()
names = [entry["name"] for entry in schema["characters"].values()]
return sorted(names, key=lambda value: value.lower())
def save_or_update_character(
character_name: str,
character_prompt: str,
aliases_csv: str | None = "",
) -> tuple[dict, dict]:
canonical = normalize_key(character_name)
if not canonical:
raise ValueError("Character name cannot be empty.")
prompt = str(character_prompt or "").strip()
if not prompt:
raise ValueError("Character prompt cannot be empty.")
schema = load_schema()
existing_entry = schema["characters"].get(canonical)
if aliases_csv is None:
aliases_raw = existing_entry.get("aliases", []) if existing_entry else []
aliases = []
seen = {canonical}
for alias_raw in aliases_raw:
alias = normalize_key(str(alias_raw))
if alias and alias not in seen:
aliases.append(alias)
seen.add(alias)
else:
aliases = [alias for alias in parse_aliases_csv(aliases_csv) if alias != canonical]
schema["characters"][canonical] = {
"name": character_name.strip() or canonical,
"prompt": prompt,
"aliases": aliases,
"updated_at": _utc_now(),
}
save_schema(schema)
return schema, schema["characters"][canonical]
def load_character(character_name: str) -> tuple[dict, str, dict]:
canonical = normalize_key(character_name)
if not canonical:
raise ValueError("Character name cannot be empty.")
schema = load_schema()
entry = schema["characters"].get(canonical)
if entry is None:
raise KeyError(f"Character '{character_name}' was not found.")
return schema, canonical, entry
def delete_character(character_name: str) -> tuple[dict, str]:
canonical = normalize_key(character_name)
if not canonical:
raise ValueError("Character name cannot be empty.")
schema = load_schema()
if canonical not in schema["characters"]:
raise KeyError(f"Character '{character_name}' was not found.")
removed_name = schema["characters"][canonical]["name"]
del schema["characters"][canonical]
save_schema(schema)
return schema, removed_name
+8 -2
View File
@@ -19,7 +19,14 @@ def _get_mappings(mod):
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
for _name in ("maxedoutnodes", "mediacomparers", "wan22nodes", "loraloader_mxd", "wan_svi_first_last_mxd"):
for _name in (
"maxedoutnodes",
"mediacomparers",
"wan22nodes",
"loraloader_mxd",
"wan_svi_first_last_mxd",
"CharacterPrompts",
):
_mod = _safe_import(_name)
_class_map, _display_map = _get_mappings(_mod)
NODE_CLASS_MAPPINGS.update(_class_map)
@@ -30,4 +37,3 @@ __all__ = [
"NODE_DISPLAY_NAME_MAPPINGS",
"WEB_DIRECTORY",
]
-87
View File
@@ -798,91 +798,6 @@ class LatentHalfMasks:
########################################################################################################################
# Grow Blur Mask MXD (single-image friendly)
class GrowBlurMaskMXD:
DESCRIPTION = """Expand or contract a mask and blur only the expanded ring (core stays solid)."""
TITLE = "Grow Blur Mask MXD"
CATEGORY = "MXD/Mask"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask": ("MASK",),
"grow_blur": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1}),
}
}
RETURN_TYPES = ("MASK",)
FUNCTION = "run"
def _normalize_mask(self, mask: torch.Tensor) -> torch.Tensor:
if not isinstance(mask, torch.Tensor):
raise ValueError("GrowBlurMaskMXD: mask must be a torch.Tensor.")
if mask.dim() == 2:
mask = mask.unsqueeze(0)
elif mask.dim() == 4 and mask.shape[-1] == 1:
mask = mask.squeeze(-1)
if mask.dim() != 3:
raise ValueError("GrowBlurMaskMXD: mask must have shape (H,W) or (B,H,W).")
return mask.float().clamp(0.0, 1.0)
def _dilate(self, mask: torch.Tensor, radius: int) -> torch.Tensor:
if radius <= 0:
return mask
x = mask.unsqueeze(1)
k = 2 * radius + 1
y = F.max_pool2d(x, kernel_size=k, stride=1, padding=radius)
return y.squeeze(1)
def _erode(self, mask: torch.Tensor, radius: int) -> torch.Tensor:
if radius <= 0:
return mask
x = mask.unsqueeze(1)
k = 2 * radius + 1
y = 1.0 - F.max_pool2d(1.0 - x, kernel_size=k, stride=1, padding=radius)
return y.squeeze(1)
def _blur_ring(self, ring: torch.Tensor, radius: int, device: torch.device) -> torch.Tensor:
if radius <= 0:
return ring
ring_cpu = ring.detach().cpu().numpy()
blurred = []
for i in range(ring_cpu.shape[0]):
arr = (ring_cpu[i] * 255.0).astype(np.uint8)
pil = Image.fromarray(arr, mode="L")
pil = pil.filter(ImageFilter.GaussianBlur(radius))
out = np.array(pil).astype(np.float32) / 255.0
blurred.append(torch.from_numpy(out))
blurred_t = torch.stack(blurred, dim=0).to(device)
return blurred_t.clamp(0.0, 1.0)
def run(self, mask, grow_blur):
mask = self._normalize_mask(mask)
device = mask.device
if grow_blur == 0:
return (mask,)
radius = abs(int(grow_blur))
if grow_blur < 0:
eroded = self._erode(mask, radius)
return (eroded.clamp(0.0, 1.0),)
core = mask
expanded = self._dilate(mask, radius)
ring = (expanded - core).clamp(0.0, 1.0)
blurred_ring = self._blur_ring(ring, radius, device)
blurred_ring = (blurred_ring * expanded).clamp(0.0, 1.0)
out = (core + blurred_ring).clamp(0.0, 1.0)
return (out,)
########################################################################################################################
# Get Latent Size
class GetLatentSizeMXD:
DESCRIPTION = """Get image width/height from a latent."""
@@ -1472,7 +1387,6 @@ NODE_CLASS_MAPPINGS = {
"FluxResolutionMatcher": FluxResolutionMatcher,
"SDXLResolutionMatcher": SDXLResolutionMatcher,
"LatentHalfMasks": LatentHalfMasks,
"Grow Blur Mask MXD": GrowBlurMaskMXD,
"Get Latent Size": GetLatentSizeMXD,
"Place Image By Mask": PlaceImageByMask,
"Crop Image By Mask": CropImageByMask,
@@ -1500,7 +1414,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FluxResolutionMatcher": "Flux Resolution Matcher MXD",
"SDXLResolutionMatcher": "SDXL Resolution Matcher MXD",
"LatentHalfMasks": "Latent to L/R Masks MXD",
"Grow Blur Mask MXD": "Grow Blur Mask MXD",
"Get Latent Size": "Get Latent Size MXD",
"Place Image By Mask": "Place Image by Mask MXD",
"Crop Image By Mask": "Crop Image by Mask MXD",
+1 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-MaxedOut"
description = "Custom ComfyUI nodes used in Maxed Out workflows (SDXL, Flux, Wan 2.2, etc.)"
version = "1.8.9"
version = "1.9.0"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
@@ -33,5 +33,3 @@ DisplayName = "Maxed Out"
Icon = "https://github.com/Maxed-Out-99/ComfyUI-MaxedOut/blob/main/assets/maxedout_icon.png?raw=true"
includes = []
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
+1
View File
@@ -2,4 +2,5 @@ import './js/betterCombos.js';
import './js/image_comparer.js';
import './addons/zip_loader/js/zip_loader.js';
import './loraloader_mxd_entry.js';
import './mxd_character_prompts.js';
+176
View File
@@ -0,0 +1,176 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const TARGET_NODE_CLASS = "MXDCharacterEditDelete";
const TARGET_PROMPT_ENCODE_CLASS = "MXDCharacterPromptEncode";
const TARGET_WIDGET_NAME = "selected_character";
const NO_CHARACTERS_OPTION = "(no characters saved)";
const PICK_CHARACTER_OPTION = "(pick character)";
async function fetchCharacterNames() {
try {
const response = await api.fetchApi("/mxd/characters/list");
if (!response.ok) {
return [];
}
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
function setComboOptions(widget, values) {
const options = values.length ? values : [NO_CHARACTERS_OPTION];
widget.options = widget.options || {};
widget.options.values = options;
if (!options.includes(widget.value)) {
widget.value = options[0];
}
}
function moveWidgetAfter(node, widgetToMove, referenceWidget) {
const widgets = node.widgets || [];
const moveIndex = widgets.indexOf(widgetToMove);
const refIndex = widgets.indexOf(referenceWidget);
if (moveIndex === -1 || refIndex === -1) {
return;
}
widgets.splice(moveIndex, 1);
const nextRefIndex = widgets.indexOf(referenceWidget);
widgets.splice(nextRefIndex + 1, 0, widgetToMove);
}
function moveWidgetBefore(node, widgetToMove, referenceWidget) {
const widgets = node.widgets || [];
const moveIndex = widgets.indexOf(widgetToMove);
const refIndex = widgets.indexOf(referenceWidget);
if (moveIndex === -1 || refIndex === -1) {
return;
}
widgets.splice(moveIndex, 1);
const nextRefIndex = widgets.indexOf(referenceWidget);
widgets.splice(nextRefIndex, 0, widgetToMove);
}
function installRefreshButton(node) {
if (node.__mxdCharacterRefreshInstalled) {
return;
}
const selectedWidget = (node.widgets || []).find((widget) => widget.name === TARGET_WIDGET_NAME);
if (!selectedWidget) {
return;
}
node.__mxdCharacterRefreshInstalled = true;
const refresh = async () => {
const names = await fetchCharacterNames();
setComboOptions(selectedWidget, names);
app.graph.setDirtyCanvas(true, true);
};
const refreshWidget = node.addWidget("button", "update list", null, refresh, {});
refreshWidget.serialize = false;
moveWidgetAfter(node, refreshWidget, selectedWidget);
refresh();
}
function appendCharacterToPrompt(node, characterName) {
if (!characterName || characterName === PICK_CHARACTER_OPTION || characterName === NO_CHARACTERS_OPTION) {
return;
}
const promptWidget = (node.widgets || []).find((widget) => widget.name === "prompt");
if (!promptWidget) {
return;
}
const currentPrompt = typeof promptWidget.value === "string" ? promptWidget.value : "";
const needsLeadingSpace = currentPrompt.length > 0 && !/\s$/.test(currentPrompt);
const prefix = needsLeadingSpace ? " " : "";
const suffix = /\s$/.test(characterName) ? "" : " ";
promptWidget.value = `${currentPrompt}${prefix}${characterName}${suffix}`;
app.graph.setDirtyCanvas(true, true);
}
function installPromptCharacterPicker(node) {
if (node.__mxdCharacterPickerInstalled) {
return;
}
const promptWidget = (node.widgets || []).find((widget) => widget.name === "prompt");
if (!promptWidget) {
return;
}
node.__mxdCharacterPickerInstalled = true;
const pickerWidget = node.addWidget(
"combo",
"character_list",
PICK_CHARACTER_OPTION,
(value) => {
appendCharacterToPrompt(node, value);
pickerWidget.value = PICK_CHARACTER_OPTION;
app.graph.setDirtyCanvas(true, true);
},
{ values: [PICK_CHARACTER_OPTION] }
);
pickerWidget.serialize = false;
const refreshPicker = async () => {
const names = await fetchCharacterNames();
const options = names.length ? [PICK_CHARACTER_OPTION, ...names] : [PICK_CHARACTER_OPTION, NO_CHARACTERS_OPTION];
pickerWidget.options = pickerWidget.options || {};
pickerWidget.options.values = options;
if (!options.includes(pickerWidget.value)) {
pickerWidget.value = PICK_CHARACTER_OPTION;
}
app.graph.setDirtyCanvas(true, true);
};
const refreshWidget = node.addWidget("button", "update list", null, refreshPicker, {});
refreshWidget.serialize = false;
const handlingWidget = (node.widgets || []).find((widget) => widget.name === "character_handling");
if (handlingWidget) {
moveWidgetBefore(node, pickerWidget, handlingWidget);
moveWidgetBefore(node, refreshWidget, handlingWidget);
}
refreshPicker();
}
app.registerExtension({
name: "mxd.character_prompts.refresh",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== TARGET_NODE_CLASS && nodeData.name !== TARGET_PROMPT_ENCODE_CLASS) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
if (nodeData.name === TARGET_NODE_CLASS) {
installRefreshButton(this);
} else if (nodeData.name === TARGET_PROMPT_ENCODE_CLASS) {
installPromptCharacterPicker(this);
}
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = onConfigure?.apply(this, arguments);
if (nodeData.name === TARGET_NODE_CLASS) {
installRefreshButton(this);
} else if (nodeData.name === TARGET_PROMPT_ENCODE_CLASS) {
installPromptCharacterPicker(this);
}
return result;
};
},
});