Initial commit — ComfyUI Discord bot + web UI

Full source for the-third-rev: Discord bot (discord.py), FastAPI web UI
(React/TS/Vite/Tailwind), ComfyUI integration, generation history DB,
preset manager, workflow inspector, and all supporting modules.

Excluded from tracking: .env, invite_tokens.json, *.db (SQLite),
current-workflow-changes.json, user_settings/, presets/, logs/,
web-static/ (build output), frontend/node_modules/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-03-02 09:55:48 +07:00
commit 1ed3c9ec4b
82 changed files with 20693 additions and 0 deletions

370
commands/presets.py Normal file
View File

@@ -0,0 +1,370 @@
"""
commands/presets.py
===================
Named workflow preset commands for the Discord ComfyUI bot.
A preset is a saved snapshot of the current workflow template and runtime
state (prompt, negative_prompt, input_image, seed). Presets make it easy
to switch between different setups (e.g. "portrait", "landscape", "anime")
with a single command.
All sub-commands are accessed through the single ``ttr!preset`` command:
ttr!preset save <name> [description:<text>] — capture current workflow + state
ttr!preset load <name> — restore workflow + state
ttr!preset list — list all saved presets
ttr!preset view <name> — show preset details
ttr!preset delete <name> — permanently remove a preset
ttr!preset save-last <name> [description:<text>] — save last generation as preset
"""
from __future__ import annotations
import logging
from discord.ext import commands
from discord_utils import require_comfy_client
from preset_manager import PresetManager
logger = logging.getLogger(__name__)
def _parse_name_and_description(args: str) -> tuple[str, str | None]:
"""
Split ``<name> [description:<text>]`` into (name, description).
The name is the first whitespace-delimited token. Everything after
``description:`` (case-insensitive) in the remaining text is the
description. Returns (name, None) if no description keyword is found.
"""
parts = args.strip().split(maxsplit=1)
name = parts[0] if parts else ""
description: str | None = None
if len(parts) > 1:
rest = parts[1]
lower = rest.lower()
idx = lower.find("description:")
if idx >= 0:
description = rest[idx + len("description:"):].strip() or None
return name, description
def setup_preset_commands(bot):
"""
Register preset commands with the bot.
Parameters
----------
bot : commands.Bot
The Discord bot instance.
"""
preset_manager = PresetManager()
@bot.command(name="preset", extras={"category": "Presets"})
@require_comfy_client
async def preset_command(ctx: commands.Context, *, args: str = "") -> None:
"""
Save, load, list, view, or delete named workflow presets.
A preset captures the current workflow template and all runtime
state changes (prompt, negative_prompt, input_image, seed) under a
short name. Load it later to restore everything in one step.
Usage:
ttr!preset save <name> [description:<text>] — save current workflow + state
ttr!preset load <name> — restore workflow + state
ttr!preset list — list all saved presets
ttr!preset view <name> — show preset details
ttr!preset delete <name> — permanently delete a preset
ttr!preset save-last <name> [description:<text>] — save last generation as preset
Names may only contain letters, digits, hyphens, and underscores.
Examples:
ttr!preset save portrait description:studio lighting style
ttr!preset load portrait
ttr!preset list
ttr!preset view portrait
ttr!preset delete portrait
ttr!preset save-last my-last
"""
parts = args.strip().split(maxsplit=1)
subcommand = parts[0].lower() if parts else ""
rest = parts[1].strip() if len(parts) > 1 else ""
if subcommand == "save":
name, description = _parse_name_and_description(rest)
await _preset_save(ctx, bot, preset_manager, name, description)
elif subcommand == "load":
await _preset_load(ctx, bot, preset_manager, rest.split()[0] if rest.split() else "")
elif subcommand == "list":
await _preset_list(ctx, preset_manager)
elif subcommand == "view":
await _preset_view(ctx, preset_manager, rest.split()[0] if rest.split() else "")
elif subcommand == "delete":
await _preset_delete(ctx, preset_manager, rest.split()[0] if rest.split() else "")
elif subcommand == "save-last":
name, description = _parse_name_and_description(rest)
await _preset_save_last(ctx, preset_manager, name, description)
else:
await ctx.reply(
"Usage: `ttr!preset <save|load|list|view|delete|save-last> [name]`\n"
"Run `ttr!help preset` for full details.",
mention_author=False,
)
async def _preset_save(
ctx: commands.Context, bot, preset_manager: PresetManager, name: str,
description: str | None = None,
) -> None:
"""Handle ttr!preset save <name> [description:<text>]."""
if not name:
await ctx.reply(
"Please provide a name. Example: `ttr!preset save portrait`",
mention_author=False,
)
return
if not PresetManager.is_valid_name(name):
await ctx.reply(
"Invalid name. Use only letters, digits, hyphens, and underscores (max 64 chars).",
mention_author=False,
)
return
try:
workflow_template = bot.comfy.get_workflow_template()
state = bot.comfy.get_workflow_current_changes()
preset_manager.save(name, workflow_template, state, description=description)
# Build a summary of what was saved
has_workflow = workflow_template is not None
state_parts = []
if state.get("prompt"):
state_parts.append("prompt")
if state.get("negative_prompt"):
state_parts.append("negative_prompt")
if state.get("input_image"):
state_parts.append("input_image")
if state.get("seed") is not None:
state_parts.append(f"seed={state['seed']}")
summary_parts = []
if has_workflow:
summary_parts.append("workflow template")
summary_parts.extend(state_parts)
summary = ", ".join(summary_parts) if summary_parts else "empty state"
desc_note = f"\n> {description}" if description else ""
await ctx.reply(
f"Preset **{name}** saved ({summary}).{desc_note}",
mention_author=False,
)
except Exception as exc:
logger.exception("Failed to save preset '%s'", name)
await ctx.reply(
f"Failed to save preset: {type(exc).__name__}: {exc}",
mention_author=False,
)
async def _preset_load(
ctx: commands.Context, bot, preset_manager: PresetManager, name: str
) -> None:
"""Handle ttr!preset load <name>."""
if not name:
await ctx.reply(
"Please provide a name. Example: `ttr!preset load portrait`",
mention_author=False,
)
return
data = preset_manager.load(name)
if data is None:
presets = preset_manager.list_presets()
hint = f" Available: {', '.join(presets)}" if presets else " No presets saved yet."
await ctx.reply(
f"Preset **{name}** not found.{hint}",
mention_author=False,
)
return
try:
restored: list[str] = []
# Restore workflow template if present
workflow = data.get("workflow")
if workflow is not None:
bot.comfy.set_workflow(workflow)
restored.append("workflow template")
# Restore state changes
state = data.get("state", {})
if state:
bot.comfy.set_workflow_current_changes(state)
if state.get("prompt"):
restored.append("prompt")
if state.get("negative_prompt"):
restored.append("negative_prompt")
if state.get("input_image"):
restored.append("input_image")
if state.get("seed") is not None:
restored.append(f"seed={state['seed']}")
summary = ", ".join(restored) if restored else "nothing (preset was empty)"
description = data.get("description")
desc_note = f"\n> {description}" if description else ""
await ctx.reply(
f"Preset **{name}** loaded ({summary}).{desc_note}",
mention_author=False,
)
except Exception as exc:
logger.exception("Failed to load preset '%s'", name)
await ctx.reply(
f"Failed to load preset: {type(exc).__name__}: {exc}",
mention_author=False,
)
async def _preset_view(
ctx: commands.Context, preset_manager: PresetManager, name: str
) -> None:
"""Handle ttr!preset view <name>."""
if not name:
await ctx.reply(
"Please provide a name. Example: `ttr!preset view portrait`",
mention_author=False,
)
return
data = preset_manager.load(name)
if data is None:
await ctx.reply(f"Preset **{name}** not found.", mention_author=False)
return
lines = [f"**Preset: {name}**"]
if data.get("description"):
lines.append(f"> {data['description']}")
if data.get("owner"):
lines.append(f"Owner: {data['owner']}")
state = data.get("state", {})
if state.get("prompt"):
# Truncate long prompts
p = str(state["prompt"])
if len(p) > 200:
p = p[:197] + ""
lines.append(f"**Prompt:** {p}")
if state.get("negative_prompt"):
np = str(state["negative_prompt"])
if len(np) > 100:
np = np[:97] + ""
lines.append(f"**Negative:** {np}")
if state.get("seed") is not None:
seed_note = " (random)" if state["seed"] == -1 else ""
lines.append(f"**Seed:** {state['seed']}{seed_note}")
other = {k: v for k, v in state.items() if k not in ("prompt", "negative_prompt", "seed", "input_image")}
if other:
other_str = ", ".join(f"{k}={v}" for k, v in other.items())
lines.append(f"**Other:** {other_str[:200]}")
if data.get("workflow") is not None:
lines.append("_(includes workflow template)_")
else:
lines.append("_(no workflow template — load separately)_")
await ctx.reply("\n".join(lines), mention_author=False)
async def _preset_list(ctx: commands.Context, preset_manager: PresetManager) -> None:
"""Handle ttr!preset list."""
presets = preset_manager.list_preset_details()
if not presets:
await ctx.reply(
"No presets saved yet. Use `ttr!preset save <name>` to create one.",
mention_author=False,
)
return
lines = [f"**Saved presets** ({len(presets)})"]
for p in presets:
entry = f"{p['name']}"
if p.get("description"):
entry += f"{p['description']}"
lines.append(entry)
lines.append("\nUse `ttr!preset load <name>` to restore one.")
await ctx.reply("\n".join(lines), mention_author=False)
async def _preset_delete(
ctx: commands.Context, preset_manager: PresetManager, name: str
) -> None:
"""Handle ttr!preset delete <name>."""
if not name:
await ctx.reply(
"Please provide a name. Example: `ttr!preset delete portrait`",
mention_author=False,
)
return
deleted = preset_manager.delete(name)
if deleted:
await ctx.reply(f"Preset **{name}** deleted.", mention_author=False)
else:
await ctx.reply(
f"Preset **{name}** not found.",
mention_author=False,
)
async def _preset_save_last(
ctx: commands.Context, preset_manager: PresetManager, name: str,
description: str | None = None,
) -> None:
"""Handle ttr!preset save-last <name> [description:<text>]."""
if not name:
await ctx.reply(
"Please provide a name. Example: `ttr!preset save-last my-last`",
mention_author=False,
)
return
if not PresetManager.is_valid_name(name):
await ctx.reply(
"Invalid name. Use only letters, digits, hyphens, and underscores (max 64 chars).",
mention_author=False,
)
return
from generation_db import get_history as db_get_history
history = db_get_history(limit=1)
if not history:
await ctx.reply(
"No generation history found. Generate something first!",
mention_author=False,
)
return
last = history[0]
overrides = last.get("overrides") or {}
try:
preset_manager.save(name, None, overrides, description=description)
desc_note = f"\n> {description}" if description else ""
await ctx.reply(
f"Preset **{name}** saved from last generation.{desc_note}\n"
"Note: workflow template not included — load it separately before generating.",
mention_author=False,
)
except ValueError as exc:
await ctx.reply(str(exc), mention_author=False)
except Exception as exc:
logger.exception("Failed to save preset '%s' from history", name)
await ctx.reply(
f"Failed to save preset: {type(exc).__name__}: {exc}",
mention_author=False,
)