Initial commit
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'bradsec' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@7578cdb0c1410f2515e6f9b1443c6859cab94f05 # v1.0.1
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Python bytecode and caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
|
||||
# Environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# AI tooling and planning artifacts (do not ship to the registry/repo)
|
||||
docs/superpowers/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
*.plan.md
|
||||
|
||||
# Private role prose - never commit (user roles stay local)
|
||||
roles/*.md
|
||||
!roles/.gitkeep
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 BRADSEC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,117 @@
|
||||
# LLM Set Role - Custom node for ComfyUI
|
||||
|
||||
A single node that presents a dropdown of LLM **roles** (system prompts) and outputs the selected role's text as a STRING. Wire that STRING into the `system_prompt` input of an LLM node (Ollama, etc.) to give the model a consistent role: a style transform, a summarizer, a translator, a captioner, an editor, anything you can write as a system prompt.
|
||||
|
||||
The node will be located under **Add Node > LLM**. Node name: **Set Role**.
|
||||
|
||||
Designed to pair with LLM nodes such as the [stavsap/comfyui-ollama](https://github.com/stavsap/comfyui-ollama) pack: this node supplies the system prompt, the LLM node does the generation.
|
||||
|
||||
## The problem it solves
|
||||
|
||||
Multi-stage prompt pipelines reuse the same long system prompts. Pasting that prose into a text box on every workflow is error-prone and hard to keep consistent. This node keeps each role as a Markdown file on disk and exposes them as a dropdown, so a workflow picks a role by name and the exact, version-controlled text flows into the LLM. Modes let you also step or randomize through your roles across a batch.
|
||||
|
||||
## Nodes included
|
||||
|
||||
- **Set Role** - dropdown of roles from `roles/*.md`, outputs the selected role text, its name, and the list position used.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `mode` | combo | `fixed` | How the role is chosen: `fixed`, `increment`, or `random`. See Selection modes below. |
|
||||
| `role` | combo | first role | Role used in `fixed` mode. The list is built from `roles/*.md` at startup. Add a `.md` file and restart ComfyUI to add a role. |
|
||||
| `index` | INT | `0` | Position used in `increment` mode. Set its `control_after_generate` to **increment** to advance one role per generation. Wraps within `start..end`. |
|
||||
| `seed` | INT | `0` | Seed used in `random` mode. Set `control_after_generate` to **randomize** for a new pick each generation; the same seed reproduces the same role. |
|
||||
| `start` | INT | `0` | Lower bound position (0-based) into the alphabetically sorted role list. |
|
||||
| `end` | INT | `-1` | Upper bound position; `-1` means the last role. Out-of-range values are clamped. |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
| `system_prompt` | STRING | The full text of the selected role file, ready to wire into an LLM node's `system_prompt` input. |
|
||||
| `role_name` | STRING | The selected role's display name, e.g. for logging or filename suffixes. |
|
||||
| `resolved_index` | INT | The list position actually used. Useful for logging or labelling batched runs. |
|
||||
|
||||
## Selection modes
|
||||
|
||||
Roles are sorted alphabetically by display name; `start`/`end` index into that
|
||||
list (0-based, `end = -1` means the last). The range is inclusive.
|
||||
|
||||
- **fixed**: output the `role` dropdown. `index`/`seed`/`start`/`end` ignored.
|
||||
- **increment**: output the role at `start + (index mod span)`, wrapping back to
|
||||
`start` after `end`. Set the `index` widget's `control_after_generate` to
|
||||
**increment** so each generation advances to the next role.
|
||||
- **random**: output a role picked from `start..end` using `seed`. The same seed
|
||||
always yields the same role; set `control_after_generate` to **randomize** for a
|
||||
fresh pick per run.
|
||||
|
||||
Note: `control_after_generate` is the same per-widget control ComfyUI puts on the
|
||||
`seed` widget of samplers. Increment/random still work without it by changing
|
||||
`index`/`seed` yourself or by using ComfyUI's batch count.
|
||||
|
||||
## Adding roles
|
||||
|
||||
1. Drop a new `.md` file into the `roles/` directory. The file content is the system prompt, kept verbatim. See `roles/example.md.example` for the format.
|
||||
2. The dropdown label defaults to the prettified filename: `summarizer.md` becomes `Summarizer`.
|
||||
3. To set an explicit label, make the **first line** of the file an HTML comment:
|
||||
|
||||
```markdown
|
||||
<!-- title: Concise Summarizer -->
|
||||
```
|
||||
|
||||
That line is stripped from the output and is used only as the dropdown label. (An HTML comment is used rather than a `#` heading because role files commonly start with their own `#` heading.)
|
||||
4. Restart ComfyUI so the new file appears in the dropdown. Editing an existing role does not need a restart: the node re-runs when the file changes.
|
||||
|
||||
Your `roles/*.md` files are gitignored, so your prompts stay private and local. The shipped `roles/example.md.example` is tracked only as a format reference and is not loaded by the node.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
[Set Role].system_prompt -> [Ollama Generate].system_prompt
|
||||
your prompt text -> [Ollama Generate].prompt
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (no ComfyUI needed)
|
||||
|
||||
```bash
|
||||
cd ComfyUI_LLMSetRole
|
||||
python -m unittest test_set_role -v
|
||||
```
|
||||
|
||||
Expect 25 passing tests (title parsing, filename prettify, label-collision disambiguation, path-traversal rejection, missing-file error, edit hot-reload, bad-encoding tolerance, range bounds/clamp/swap, increment wrap, random determinism, mode selection, shipped-role discovery).
|
||||
|
||||
### In ComfyUI
|
||||
|
||||
1. Restart ComfyUI so the node loads. Confirm it appears once under **Add Node > LLM** as **Set Role**, with no import error in `user/comfyui.log`.
|
||||
2. Add the node, pick a role and mode, and wire `system_prompt` into your LLM node.
|
||||
3. Run the workflow. The LLM node receives the selected role text.
|
||||
|
||||
## Installation
|
||||
|
||||
### ComfyUI Manager (recommended)
|
||||
|
||||
In ComfyUI, open **Manager > Custom Nodes Manager**, search for **ComfyUI_LLMSetRole**, click **Install**, then restart ComfyUI.
|
||||
|
||||
### Manual install
|
||||
|
||||
Clone into your ComfyUI `custom_nodes` directory and restart ComfyUI:
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/bradsec/ComfyUI_LLMSetRole
|
||||
```
|
||||
|
||||
No extra dependencies (Python stdlib only).
|
||||
|
||||
## Compatibility
|
||||
|
||||
Dual API: exports the legacy V1 `NODE_CLASS_MAPPINGS` and a V3 `comfy_entrypoint` (`comfy_api`). On builds whose loader reads `NODE_CLASS_MAPPINGS` first, the V1 path is used and `comfy_entrypoint` is skipped, so the node is never registered twice.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If you find this useful, please consider [starring the repo](https://github.com/bradsec/ComfyUI_LLMSetRole). Stars help other people discover these nodes.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
"""ComfyUI_LLMSetRole: dropdown of system-prompt roles for LLM nodes.
|
||||
|
||||
Exports V1 NODE_CLASS_MAPPINGS (authoritative on the 0.25.0 if/elif loader) and
|
||||
comfy_entrypoint for builds whose loader prefers the V3 schema API.
|
||||
"""
|
||||
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
try:
|
||||
from .nodes import comfy_entrypoint # noqa: F401
|
||||
__all__.append("comfy_entrypoint")
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -0,0 +1,134 @@
|
||||
"""ComfyUI_LLMSetRole nodes.
|
||||
|
||||
A role picker: a dropdown of system-prompt roles loaded from roles/*.md, output
|
||||
as a STRING to wire into an LLM node's system_prompt input. A mode control
|
||||
selects the role by fixed dropdown, by an incrementing index, or randomly by
|
||||
seed, bounded to a start..end slice of the list.
|
||||
|
||||
Dual-API: the shared core (role_core.select) is wrapped by a V1 class
|
||||
(NODE_CLASS_MAPPINGS, authoritative on the 0.25.0 if/elif loader) and an
|
||||
import-guarded V3 class (comfy_entrypoint) for builds whose loader prefers the
|
||||
comfy_api schema. node_id "LLMSetRole" is fixed in both.
|
||||
"""
|
||||
|
||||
from . import role_core as core
|
||||
|
||||
NODE_ID = "LLMSetRole"
|
||||
DISPLAY_NAME = "Set Role"
|
||||
CATEGORY = "LLM"
|
||||
DESCRIPTION = "Pick an LLM role system prompt from roles/*.md (fixed, increment, or random) and output it as a STRING for an LLM node's system_prompt."
|
||||
|
||||
_ROLE_TIP = ("Role used in 'fixed' mode. The list is built from roles/*.md at "
|
||||
"startup; add a .md file and restart ComfyUI to add a role.")
|
||||
_MODE_TIP = ("fixed: use the role dropdown. increment: walk the list by 'index', "
|
||||
"wrapping within start..end. random: pick within start..end from 'seed'.")
|
||||
_INDEX_TIP = ("Position used in 'increment' mode. Set control_after_generate to "
|
||||
"increment to advance one role per generation. Wraps within start..end.")
|
||||
_SEED_TIP = ("Seed used in 'random' mode. Set control_after_generate to randomize for "
|
||||
"a new pick per generation; the same seed reproduces the same role.")
|
||||
_START_TIP = "Lower bound position (0-based) into the alphabetically sorted role list."
|
||||
_END_TIP = "Upper bound position; -1 means the last role. Out-of-range values are clamped."
|
||||
|
||||
# Combo options built once at import. role_core guarantees a non-empty list.
|
||||
_ROLE_OPTIONS = core.role_labels()
|
||||
_DEFAULT_ROLE = _ROLE_OPTIONS[0]
|
||||
_MAX_INT = 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
# -- V1 node -------------------------------------------------------------------
|
||||
|
||||
class LLMSetRoleNode:
|
||||
CATEGORY = CATEGORY
|
||||
FUNCTION = "set_role"
|
||||
OUTPUT_NODE = False
|
||||
DESCRIPTION = DESCRIPTION
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("system_prompt", "role_name", "resolved_index")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"mode": (list(core.MODES), {"default": "fixed", "tooltip": _MODE_TIP}),
|
||||
"role": (_ROLE_OPTIONS, {"default": _DEFAULT_ROLE, "tooltip": _ROLE_TIP}),
|
||||
"index": ("INT", {"default": 0, "min": 0, "max": _MAX_INT,
|
||||
"control_after_generate": True, "tooltip": _INDEX_TIP}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": _MAX_INT,
|
||||
"control_after_generate": True, "tooltip": _SEED_TIP}),
|
||||
"start": ("INT", {"default": 0, "min": 0, "max": _MAX_INT, "tooltip": _START_TIP}),
|
||||
"end": ("INT", {"default": -1, "min": -1, "max": _MAX_INT, "tooltip": _END_TIP}),
|
||||
}
|
||||
}
|
||||
|
||||
def set_role(self, mode, role, index, seed, start, end):
|
||||
return core.select(mode, role, index, seed, start, end)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, mode, role, index, seed, start, end):
|
||||
return core.select_fingerprint(mode, role, index, seed, start, end)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {NODE_ID: LLMSetRoleNode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {NODE_ID: DISPLAY_NAME}
|
||||
|
||||
|
||||
# -- V3 node (import-guarded comfy_api schema) ---------------------------------
|
||||
|
||||
try:
|
||||
from comfy_api.v0_0_2 import io, ComfyExtension
|
||||
|
||||
def _int_input(name, *, control=False, **kw):
|
||||
"""io.Int.Input, tolerating comfy_api builds without control_after_generate."""
|
||||
if control:
|
||||
try:
|
||||
return io.Int.Input(name, control_after_generate=True, **kw)
|
||||
except TypeError:
|
||||
pass
|
||||
return io.Int.Input(name, **kw)
|
||||
|
||||
class LLMSetRoleV3(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id=NODE_ID,
|
||||
display_name=DISPLAY_NAME,
|
||||
category=CATEGORY,
|
||||
description=DESCRIPTION,
|
||||
inputs=[
|
||||
io.Combo.Input("mode", options=list(core.MODES),
|
||||
default="fixed", tooltip=_MODE_TIP),
|
||||
io.Combo.Input("role", options=_ROLE_OPTIONS,
|
||||
default=_DEFAULT_ROLE, tooltip=_ROLE_TIP),
|
||||
_int_input("index", control=True, default=0, min=0, max=_MAX_INT,
|
||||
tooltip=_INDEX_TIP),
|
||||
_int_input("seed", control=True, default=0, min=0, max=_MAX_INT,
|
||||
tooltip=_SEED_TIP),
|
||||
_int_input("start", default=0, min=0, max=_MAX_INT, tooltip=_START_TIP),
|
||||
_int_input("end", default=-1, min=-1, max=_MAX_INT, tooltip=_END_TIP),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(id="system_prompt", display_name="system_prompt"),
|
||||
io.String.Output(id="role_name", display_name="role_name"),
|
||||
io.Int.Output(id="resolved_index", display_name="resolved_index"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fingerprint_inputs(cls, mode, role, index, seed, start, end):
|
||||
return core.select_fingerprint(mode, role, index, seed, start, end)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, mode, role, index, seed, start, end) -> io.NodeOutput:
|
||||
body, title, pos = core.select(mode, role, index, seed, start, end)
|
||||
return io.NodeOutput(body, title, pos)
|
||||
|
||||
class LLMSetRoleExtension(ComfyExtension):
|
||||
async def get_node_list(self):
|
||||
return [LLMSetRoleV3]
|
||||
|
||||
async def comfy_entrypoint() -> "LLMSetRoleExtension":
|
||||
return LLMSetRoleExtension()
|
||||
|
||||
except ImportError:
|
||||
# Older ComfyUI without comfy_api: V1 mappings above are the only path.
|
||||
pass
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "comfyui_llmsetrole"
|
||||
description = "Pick an LLM role (system prompt) from a dropdown and output it as a STRING for an LLM node's system_prompt. Modes: fixed, increment, or random over a start..end slice, with index/seed control_after_generate. Roles are plain Markdown files in roles/; add a .md file and restart to add a role. Node name: Set Role. Located under Add Node > LLM."
|
||||
version = "1.0.0"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/bradsec/ComfyUI_LLMSetRole"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "bradsec"
|
||||
DisplayName = "ComfyUI_LLMSetRole"
|
||||
Icon = ""
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
"""Shared core for ComfyUI_LLMSetRole.
|
||||
|
||||
Pure functions, no ComfyUI import. Scans roles/*.md, resolves dropdown labels,
|
||||
and loads role text. Both API wrappers (V1 and V3) call into here so the logic
|
||||
cannot drift.
|
||||
|
||||
Role files are plain Markdown kept verbatim as the LLM system prompt. A file
|
||||
may declare its dropdown label on its first line as an HTML comment:
|
||||
|
||||
<!-- title: Summarizer -->
|
||||
|
||||
When present that line is stripped from the emitted text. Without it the label
|
||||
is derived from the filename (professional_photo.md -> "Professional Photo").
|
||||
|
||||
Edit a role and the node re-runs (mtime fingerprint). Add a new role file and
|
||||
restart ComfyUI, since the dropdown options are built once at import.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
MODES = ("fixed", "increment", "random")
|
||||
|
||||
ROLES_DIR = os.path.join(os.path.dirname(__file__), "roles")
|
||||
|
||||
NO_ROLES_LABEL = "(no roles found)"
|
||||
|
||||
# First-line title marker: <!-- title: Some Name -->
|
||||
_TITLE_RE = re.compile(r"^\s*<!--\s*title:\s*(.+?)\s*-->\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def prettify_filename(filename):
|
||||
"""professional_photo.md -> 'Professional Photo'."""
|
||||
stem = os.path.splitext(filename)[0]
|
||||
return stem.replace("_", " ").replace("-", " ").strip().title()
|
||||
|
||||
|
||||
def parse_title(text, filename):
|
||||
"""Return (title, body).
|
||||
|
||||
If the first line is an HTML title comment, use it as the title and drop
|
||||
that line from the body. Otherwise the title is the prettified filename and
|
||||
the body is the text unchanged.
|
||||
"""
|
||||
lines = text.split("\n", 1)
|
||||
m = _TITLE_RE.match(lines[0]) if lines else None
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
return title, body
|
||||
return prettify_filename(filename), text
|
||||
|
||||
|
||||
def _validate_name(filename):
|
||||
"""Reject anything but a bare basename, so a label can never escape ROLES_DIR."""
|
||||
if os.path.sep in filename or (os.path.altsep and os.path.altsep in filename) \
|
||||
or filename in (os.curdir, os.pardir):
|
||||
raise ValueError(f"Invalid role filename: {filename!r}")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _load_cached(filename, mtime):
|
||||
"""Read and parse one role file, keyed on (filename, mtime).
|
||||
|
||||
Including mtime in the key means editing a file invalidates the cached
|
||||
entry on the next access, so edits are picked up without a restart.
|
||||
errors="replace": one mis-encoded role file must not crash the import-time
|
||||
dropdown build and take the whole node down; it degrades to replacement chars.
|
||||
"""
|
||||
with open(os.path.join(ROLES_DIR, filename), encoding="utf-8", errors="replace") as f:
|
||||
return parse_title(f.read(), filename)
|
||||
|
||||
|
||||
def load_role_text(filename):
|
||||
"""Return (title, body) for one role file.
|
||||
|
||||
Cached on the file's mtime: editing a role is picked up on the next run
|
||||
without restarting ComfyUI; only adding or removing files needs a restart.
|
||||
"""
|
||||
_validate_name(filename)
|
||||
path = os.path.join(ROLES_DIR, filename)
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
raise FileNotFoundError(
|
||||
f"Role file not found: {filename!r} in {ROLES_DIR}. "
|
||||
f"It may have been removed; restart ComfyUI to refresh the list."
|
||||
)
|
||||
return _load_cached(filename, mtime)
|
||||
|
||||
|
||||
def _scan_files():
|
||||
"""Sorted list of *.md basenames in ROLES_DIR, or [] if none/missing."""
|
||||
if not os.path.isdir(ROLES_DIR):
|
||||
return []
|
||||
return sorted(
|
||||
e.name for e in os.scandir(ROLES_DIR)
|
||||
if e.is_file() and e.name.lower().endswith(".md")
|
||||
)
|
||||
|
||||
|
||||
def list_roles():
|
||||
"""Return [(label, filename)] sorted by label.
|
||||
|
||||
Labels that collide after prettifying are disambiguated by appending the
|
||||
filename, so every label maps to exactly one file.
|
||||
"""
|
||||
loaded = [(fn, load_role_text(fn)[0]) for fn in _scan_files()]
|
||||
counts = {}
|
||||
for _, title in loaded:
|
||||
counts[title] = counts.get(title, 0) + 1
|
||||
|
||||
pairs = [(f"{title} ({fn})" if counts[title] > 1 else title, fn)
|
||||
for fn, title in loaded]
|
||||
pairs.sort(key=lambda p: p[0].lower())
|
||||
return pairs
|
||||
|
||||
|
||||
def role_labels():
|
||||
"""Combo option list. Never empty: falls back to a sentinel label."""
|
||||
labels = [label for label, _ in list_roles()]
|
||||
return labels or [NO_ROLES_LABEL]
|
||||
|
||||
|
||||
def resolve_filename(label):
|
||||
"""Map a dropdown label back to its role filename.
|
||||
|
||||
Raises ValueError if the label is the no-roles sentinel or is unknown, so a
|
||||
crafted label can never reference a file outside ROLES_DIR.
|
||||
"""
|
||||
if label == NO_ROLES_LABEL:
|
||||
raise ValueError(
|
||||
f"No role files found in {ROLES_DIR}. Add a .md file and restart ComfyUI."
|
||||
)
|
||||
for lbl, fn in list_roles():
|
||||
if lbl == label:
|
||||
return fn
|
||||
raise ValueError(f"Unknown role: {label!r}. Restart ComfyUI to refresh the list.")
|
||||
|
||||
|
||||
def apply_role(label):
|
||||
"""Resolve a role label to (system_prompt, role_name)."""
|
||||
fn = resolve_filename(label)
|
||||
title, body = load_role_text(fn)
|
||||
return body, title
|
||||
|
||||
|
||||
def role_fingerprint(label):
|
||||
"""Identity for caching: filename + mtime, or the label itself if unresolved."""
|
||||
try:
|
||||
fn = resolve_filename(label)
|
||||
mtime = os.path.getmtime(os.path.join(ROLES_DIR, fn))
|
||||
return f"{fn}:{mtime}"
|
||||
except (ValueError, FileNotFoundError, OSError):
|
||||
return str(label)
|
||||
|
||||
|
||||
# -- Position-based selection (fixed / increment / random) ---------------------
|
||||
|
||||
def _clamp(v, lo, hi):
|
||||
return lo if v < lo else hi if v > hi else v
|
||||
|
||||
|
||||
def _bounds(start, end, n):
|
||||
"""Return (lo, hi, span) inclusive position bounds within a list of size n.
|
||||
|
||||
end < 0 means "last". Out-of-range values are clamped; reversed bounds are
|
||||
swapped. Assumes n >= 1 (callers handle the empty case first).
|
||||
"""
|
||||
lo = _clamp(start, 0, n - 1)
|
||||
hi = _clamp(end if end >= 0 else n - 1, 0, n - 1)
|
||||
if lo > hi:
|
||||
lo, hi = hi, lo
|
||||
return lo, hi, hi - lo + 1
|
||||
|
||||
|
||||
def label_index(label):
|
||||
"""Position of a dropdown label in the sorted role list, or 0 if absent."""
|
||||
for i, (lbl, _) in enumerate(list_roles()):
|
||||
if lbl == label:
|
||||
return i
|
||||
return 0
|
||||
|
||||
|
||||
def pick_index(mode, index, seed, start, end, n):
|
||||
"""Resolve a role position for increment/random modes.
|
||||
|
||||
increment: lo + (index mod span), wrapping at the end back to the start.
|
||||
random: lo + Random(seed).randrange(span), deterministic per seed.
|
||||
Raises ValueError if there are no roles.
|
||||
"""
|
||||
if n < 1:
|
||||
raise ValueError(
|
||||
f"No role files found in {ROLES_DIR}. Add a .md file and restart ComfyUI."
|
||||
)
|
||||
lo, _, span = _bounds(start, end, n)
|
||||
if mode == "random":
|
||||
return lo + random.Random(seed).randrange(span)
|
||||
# increment (and any unknown mode falls back to deterministic stepping)
|
||||
return lo + (index % span)
|
||||
|
||||
|
||||
def role_at(pos):
|
||||
"""(label, filename) at a position. Raises ValueError if no roles."""
|
||||
roles = list_roles()
|
||||
if not roles:
|
||||
raise ValueError(
|
||||
f"No role files found in {ROLES_DIR}. Add a .md file and restart ComfyUI."
|
||||
)
|
||||
return roles[pos]
|
||||
|
||||
|
||||
def select(mode, role, index, seed, start, end):
|
||||
"""Resolve (system_prompt, role_name, resolved_index) for any mode.
|
||||
|
||||
fixed: use the dropdown `role`. increment/random: compute the position from
|
||||
index/seed within [start, end].
|
||||
"""
|
||||
if mode == "fixed":
|
||||
pos = label_index(role)
|
||||
body, title = apply_role(role)
|
||||
return body, title, pos
|
||||
pos = pick_index(mode, index, seed, start, end, len(list_roles()))
|
||||
_, fn = role_at(pos)
|
||||
title, body = load_role_text(fn)
|
||||
return body, title, pos
|
||||
|
||||
|
||||
def select_fingerprint(mode, role, index, seed, start, end):
|
||||
"""Cache identity covering inputs and the resolved file's mtime."""
|
||||
try:
|
||||
_, _, pos = select(mode, role, index, seed, start, end)
|
||||
_, fn = role_at(pos)
|
||||
mtime = os.path.getmtime(os.path.join(ROLES_DIR, fn))
|
||||
return f"{mode}:{pos}:{fn}:{mtime}"
|
||||
except (ValueError, FileNotFoundError, OSError):
|
||||
return f"{mode}:{role}:{index}:{seed}:{start}:{end}"
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- title: Summarizer -->
|
||||
You are a precise summarizer. Given any input text, return a faithful summary
|
||||
in 3 to 5 sentences. Preserve key facts, names, numbers, and conclusions. Do
|
||||
not add opinions, preamble, or closing remarks. Output only the summary.
|
||||
|
||||
# How to use this file
|
||||
|
||||
This is an EXAMPLE role, shown so you can see the format. It is named
|
||||
`example.md.example` so the node does NOT load it (the node only reads `*.md`).
|
||||
|
||||
To create a real role:
|
||||
1. Copy this file to `roles/<your_role>.md` (note: `.md`, not `.md.example`).
|
||||
2. Replace this content with your system prompt. The whole file becomes the
|
||||
prompt text sent to the LLM as its system role.
|
||||
3. Optionally set the dropdown label with an HTML comment on the FIRST line:
|
||||
`<!-- title: Your Label -->`. That line is stripped from the output. Without
|
||||
it the label is the prettified filename (`my_role.md` -> "My Role").
|
||||
4. Restart ComfyUI so the new role appears in the dropdown.
|
||||
|
||||
Your `roles/*.md` files are gitignored, so your prompts stay private and local.
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Unit tests for the LLMSetRole shared core.
|
||||
|
||||
Pure logic, no ComfyUI required.
|
||||
Run: python -m unittest test_set_role -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import role_core as core
|
||||
|
||||
|
||||
class TestParseTitle(unittest.TestCase):
|
||||
def test_html_title_extracted_and_stripped(self):
|
||||
text = "<!-- title: Summarizer -->\n# RULE\nbody line\n"
|
||||
title, body = core.parse_title(text, "whatever.md")
|
||||
self.assertEqual(title, "Summarizer")
|
||||
self.assertEqual(body, "# RULE\nbody line\n")
|
||||
|
||||
def test_title_case_insensitive_marker(self):
|
||||
title, body = core.parse_title("<!-- TITLE: Foo Bar -->\nx", "f.md")
|
||||
self.assertEqual(title, "Foo Bar")
|
||||
self.assertEqual(body, "x")
|
||||
|
||||
def test_no_title_uses_prettified_filename(self):
|
||||
text = "# CRITICAL OUTPUT RULE\nprose"
|
||||
title, body = core.parse_title(text, "professional_photo.md")
|
||||
self.assertEqual(title, "Professional Photo")
|
||||
self.assertEqual(body, text)
|
||||
|
||||
def test_heading_is_not_treated_as_title(self):
|
||||
# Files that begin with a '#' heading must not turn it into the title.
|
||||
title, _ = core.parse_title("# CRITICAL OUTPUT RULE\nx", "selfie_photo.md")
|
||||
self.assertEqual(title, "Selfie Photo")
|
||||
|
||||
|
||||
class TestPrettify(unittest.TestCase):
|
||||
def test_underscores_and_hyphens(self):
|
||||
self.assertEqual(core.prettify_filename("professional_photo.md"), "Professional Photo")
|
||||
self.assertEqual(core.prettify_filename("film-noir.md"), "Film Noir")
|
||||
|
||||
|
||||
class TestResolveSecurity(unittest.TestCase):
|
||||
def test_reject_path_separator(self):
|
||||
with self.assertRaises(ValueError):
|
||||
core.load_role_text("../secret.md")
|
||||
|
||||
def test_reject_nested_path(self):
|
||||
with self.assertRaises(ValueError):
|
||||
core.load_role_text("sub/dir.md")
|
||||
|
||||
def test_unknown_label_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
core.resolve_filename("Definitely Not A Real Role")
|
||||
|
||||
def test_no_roles_sentinel_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
core.resolve_filename(core.NO_ROLES_LABEL)
|
||||
|
||||
|
||||
class TestMissingFile(unittest.TestCase):
|
||||
def test_load_missing_raises(self):
|
||||
core._load_cached.cache_clear()
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
core.load_role_text("does_not_exist_12345.md")
|
||||
|
||||
|
||||
class TestCollisionDisambiguation(unittest.TestCase):
|
||||
def test_same_title_gets_filename_suffix(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for name in ("a.md", "b.md"):
|
||||
with open(os.path.join(d, name), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- title: Same -->\nbody")
|
||||
orig_dir = core.ROLES_DIR
|
||||
core.ROLES_DIR = d
|
||||
core._load_cached.cache_clear()
|
||||
try:
|
||||
labels = [lbl for lbl, _ in core.list_roles()]
|
||||
finally:
|
||||
core.ROLES_DIR = orig_dir
|
||||
core._load_cached.cache_clear()
|
||||
self.assertEqual(labels, ["Same (a.md)", "Same (b.md)"])
|
||||
|
||||
|
||||
class TestHotReload(unittest.TestCase):
|
||||
def test_edit_picked_up_via_mtime(self):
|
||||
# Regression: a content edit must not return a stale cached body.
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "s.md")
|
||||
orig_dir = core.ROLES_DIR
|
||||
core.ROLES_DIR = d
|
||||
core._load_cached.cache_clear()
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("<!-- title: S -->\nfirst")
|
||||
os.utime(path, (1000, 1000))
|
||||
_, body1 = core.load_role_text("s.md")
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("<!-- title: S -->\nsecond")
|
||||
os.utime(path, (2000, 2000))
|
||||
_, body2 = core.load_role_text("s.md")
|
||||
finally:
|
||||
core.ROLES_DIR = orig_dir
|
||||
core._load_cached.cache_clear()
|
||||
self.assertEqual(body1, "first")
|
||||
self.assertEqual(body2, "second")
|
||||
|
||||
|
||||
class TestBadEncoding(unittest.TestCase):
|
||||
def test_non_utf8_file_does_not_crash(self):
|
||||
# A mis-encoded file must not crash the dropdown build.
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with open(os.path.join(d, "bad.md"), "wb") as f:
|
||||
f.write(b"<!-- title: Bad -->\n\xff\xfe body")
|
||||
orig_dir = core.ROLES_DIR
|
||||
core.ROLES_DIR = d
|
||||
core._load_cached.cache_clear()
|
||||
try:
|
||||
labels = [lbl for lbl, _ in core.list_roles()]
|
||||
body, _ = core.apply_role("Bad")
|
||||
finally:
|
||||
core.ROLES_DIR = orig_dir
|
||||
core._load_cached.cache_clear()
|
||||
self.assertEqual(labels, ["Bad"])
|
||||
self.assertIn("body", body)
|
||||
|
||||
|
||||
class TestBounds(unittest.TestCase):
|
||||
def test_full_range_with_end_minus_one(self):
|
||||
self.assertEqual(core._bounds(0, -1, 3), (0, 2, 3))
|
||||
|
||||
def test_clamp_out_of_range(self):
|
||||
self.assertEqual(core._bounds(-5, 99, 3), (0, 2, 3))
|
||||
|
||||
def test_subrange(self):
|
||||
self.assertEqual(core._bounds(1, 2, 4), (1, 2, 2))
|
||||
|
||||
def test_swapped_bounds(self):
|
||||
self.assertEqual(core._bounds(2, 0, 3), (0, 2, 3))
|
||||
|
||||
|
||||
class TestPickIndex(unittest.TestCase):
|
||||
def test_increment_wraps(self):
|
||||
got = [core.pick_index("increment", i, 0, 0, -1, 3) for i in range(5)]
|
||||
self.assertEqual(got, [0, 1, 2, 0, 1])
|
||||
|
||||
def test_increment_within_subrange(self):
|
||||
got = [core.pick_index("increment", i, 0, 1, 2, 3) for i in range(4)]
|
||||
self.assertEqual(got, [1, 2, 1, 2])
|
||||
|
||||
def test_random_deterministic_per_seed(self):
|
||||
a = core.pick_index("random", 0, 42, 0, -1, 3)
|
||||
b = core.pick_index("random", 0, 42, 0, -1, 3)
|
||||
self.assertEqual(a, b)
|
||||
self.assertTrue(0 <= a <= 2)
|
||||
|
||||
def test_random_stays_in_subrange(self):
|
||||
for s in range(50):
|
||||
p = core.pick_index("random", 0, s, 1, 2, 3)
|
||||
self.assertIn(p, (1, 2))
|
||||
|
||||
def test_no_roles_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
core.pick_index("increment", 0, 0, 0, -1, 0)
|
||||
|
||||
|
||||
class TestSelect(unittest.TestCase):
|
||||
def _make(self, d, names):
|
||||
for n in names:
|
||||
with open(os.path.join(d, n), "w", encoding="utf-8") as f:
|
||||
f.write(f"<!-- title: {n[:-3].upper()} -->\nbody-{n}")
|
||||
|
||||
def test_select_modes(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
self._make(d, ("a.md", "b.md", "c.md")) # titles A, B, C -> positions 0,1,2
|
||||
orig = core.ROLES_DIR
|
||||
core.ROLES_DIR = d
|
||||
core._load_cached.cache_clear()
|
||||
try:
|
||||
body, name, pos = core.select("fixed", "B", 0, 0, 0, -1)
|
||||
self.assertEqual((name, pos, body), ("B", 1, "body-b.md"))
|
||||
|
||||
body, name, pos = core.select("increment", "A", 2, 0, 0, -1)
|
||||
self.assertEqual((name, pos), ("C", 2))
|
||||
|
||||
body, name, pos = core.select("increment", "A", 3, 0, 0, -1)
|
||||
self.assertEqual((name, pos), ("A", 0)) # wrapped
|
||||
|
||||
_, name_r, pos_r = core.select("random", "A", 0, 7, 0, -1)
|
||||
self.assertIn(pos_r, (0, 1, 2))
|
||||
self.assertIn(name_r, ("A", "B", "C"))
|
||||
finally:
|
||||
core.ROLES_DIR = orig
|
||||
core._load_cached.cache_clear()
|
||||
|
||||
|
||||
class TestShippedRoles(unittest.TestCase):
|
||||
def test_shipped_roles_present(self):
|
||||
core._load_cached.cache_clear()
|
||||
labels = dict((lbl, fn) for lbl, fn in core.list_roles())
|
||||
self.assertIn("Professional Photo", labels)
|
||||
self.assertIn("Selfie Photo", labels)
|
||||
|
||||
def test_apply_returns_body_and_title(self):
|
||||
core._load_cached.cache_clear()
|
||||
body, title = core.apply_role("Professional Photo")
|
||||
self.assertEqual(title, "Professional Photo")
|
||||
self.assertTrue(body.strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user