Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

# ComfyInject

** This is just a minor modification of https://github.com/Spadic21/ComfyInject to introduce:

- Placeholders for CHAR_AVATAR and USER_AVATAR - I just didn't get what I was looking for, so now optional placeholders can be used (note you need to include those properly in your workflow).
- A minor change so the ComfyInject model can be chosen via select, instead of a textfield.

And that's it **

*************************************************************************************************************

A SillyTavern extension that automatically generates images from `[[IMG: ... ]]` markers in bot messages using your local ComfyUI instance.

When your LLM outputs a marker, ComfyInject intercepts it, sends the prompt to ComfyUI, and replaces the marker with the generated image, all without leaving the chat. Multiple images per message are supported. Images are saved permanently into the chat history and survive page reloads. Outbound prompts sent to the LLM replace injected images with a compact token so the model maintains visual continuity across the conversation.
Expand Down
10 changes: 10 additions & 0 deletions scripts/generate-workflow-index.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const fs = require('fs');
const path = require('path');

const workflowsDir = path.join(__dirname, '..', 'workflows');
const files = fs.readdirSync(workflowsDir)
.filter(f => f.endsWith('.json') && f !== 'index.json')
.sort();

fs.writeFileSync(path.join(workflowsDir, 'index.json'), JSON.stringify(files, null, 2) + '\n');
console.log(`Generated workflows/index.json with ${files.length} workflow(s):\n ${files.join('\n ')}`);
23 changes: 8 additions & 15 deletions settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,19 @@
</div>

<!-- Checkpoint -->
<div>
<div class="flex-container flexGap5 alignItemsCenter">
<label for="comfyinject_checkpoint">Checkpoint</label>
<div style="position: relative; margin-top: 4px;">
<div class="flex-container flexGap5 alignItemsCenter">
<input id="comfyinject_checkpoint" type="text" class="text_pole" placeholder="your_model.safetensors" style="flex: 1;" />
<div id="comfyinject_checkpoint_arrow" class="menu_button" title="Show available checkpoints" style="padding: 2px 8px; cursor: pointer;">
<i class="fa-solid fa-caret-down"></i>
</div>
</div>
<div id="comfyinject_checkpoint_dropdown" style="display: none; position: absolute; z-index: 999; width: 100%; max-height: 200px; overflow-y: auto; background: var(--SmartThemeBlurTintColor); border: 1px solid var(--SmartThemeBorderColor); border-radius: 4px; margin-top: 2px;">
<!-- Populated by ui.js -->
</div>
</div>
<select id="comfyinject_checkpoint" class="text_pole">
<option value="">-- Fetching checkpoints --</option>
</select>
</div>

<!-- Workflow -->
<div>
<div class="flex-container flexGap5 alignItemsCenter">
<label for="comfyinject_workflow">Workflow</label>
<small style="display: block; margin-bottom: 4px;">Filename of the workflow JSON in the workflows folder.</small>
<input id="comfyinject_workflow" type="text" class="text_pole" placeholder="comfyinject_default.json" />
<select id="comfyinject_workflow" class="text_pole">
<option value="">-- Select workflow --</option>
</select>
</div>

<hr class="sysHR" />
Expand Down
81 changes: 79 additions & 2 deletions src/comfy.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { MODULE_NAME } from "../settings.js";
import { resolveSeed } from "./state.js";
import {
formatCharacterAvatar,
getCharacterAvatar,
user_avatar,
getUserAvatar,
} from '../../../../../script.js';
import { getBase64Async } from '../../../../utils.js';

const EXTENSION_FOLDER = `scripts/extensions/third-party/ComfyInject`;

Expand Down Expand Up @@ -121,6 +128,69 @@ function buildImageUrl(filename, subfolder, host) {
return `${host}/view?${params.toString()}`;
}

/**
* Gets the active character's avatar URL (or group fallback).
*/
function getCharacterAvatarUrl() {
const context = SillyTavern.getContext();
if (context.groupId) {
const groupMembers = context.groups.find(x => x.id === context.groupId)?.members;
const lastMessageAvatar = context.chat?.filter(x => !x.is_system && !x.is_user)?.slice(-1)[0]?.original_avatar;
const randomMemberAvatar = Array.isArray(groupMembers) ? groupMembers[Math.floor(Math.random() * groupMembers.length)] : null;
const avatarToUse = lastMessageAvatar || randomMemberAvatar;
return formatCharacterAvatar(avatarToUse);
} else {
return getCharacterAvatar(context.characterId);
}
}

/**
* Gets the active user's avatar URL.
*/
function getUserAvatarUrl() {
return getUserAvatar(user_avatar);
}

/**
* Fetch avatar image and return as base64 string.
* @param {string} url - URL of the avatar to fetch
* @returns {Promise<string>} Base64-encoded image data
*/
async function fetchAvatarAsBase64(url) {
if (!url) return "";
const response = await fetch(url);
if (!response.ok) return "";
const blob = await response.blob();
const dataUrl = await getBase64Async(blob);
return dataUrl.split(",")[1] || "";
}

/**
* Fetch character avatar for {{CHAR_AVATAR}} placeholder.
* @returns {Promise<string>}
*/
async function fetchCharacterAvatar() {
try {
return await fetchAvatarAsBase64(getCharacterAvatarUrl());
} catch (e) {
console.warn("[ComfyInject] Error fetching character avatar:", e);
return "";
}
}

/**
* Fetch user avatar for {{USER_AVATAR}} placeholder.
* @returns {Promise<string>}
*/
async function fetchUserAvatar() {
try {
return await fetchAvatarAsBase64(getUserAvatarUrl());
} catch (e) {
console.warn("[ComfyInject] Error fetching user avatar:", e);
return "";
}
}

/**
* Main entry point. Takes parsed marker data and returns a usable image URL.
* @param {object} params
Expand Down Expand Up @@ -159,8 +229,13 @@ export async function generateImage({ prompt, ar, shot, seed, messageIndex, bypa
? resolveSeed(settings.seed_lock_mode === "CUSTOM" ? settings.seed_lock_value : settings.seed_lock_mode, messageIndex)
: seed;

// Load and fill the workflow
const workflow = await loadWorkflow();
// Load workflow and avatars in parallel
const [workflow, charAvatar, userAvatar] = await Promise.all([
loadWorkflow(),
fetchCharacterAvatar().catch(() => ""),
fetchUserAvatar().catch(() => ""),
]);

const filled = fillWorkflow(workflow, {
CHECKPOINT: settings.checkpoint,
POSITIVE_PROMPT: positivePrompt,
Expand All @@ -173,6 +248,8 @@ export async function generateImage({ prompt, ar, shot, seed, messageIndex, bypa
SAMPLER: settings.sampler,
SCHEDULER: settings.scheduler,
DENOISE: settings.denoise,
CHAR_AVATAR: charAvatar,
USER_AVATAR: userAvatar,
});

// Submit to ComfyUI and wait for the result
Expand Down
123 changes: 41 additions & 82 deletions src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,52 +31,55 @@ async function fetchCheckpoints() {
if (!response.ok) return [];
const data = await response.json();
return data?.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0] ?? [];
} catch (err) {
} catch {
return [];
}
}

/**
* Validates that a workflow file exists in the workflows folder.
* Shows a toastr error if the file doesn't exist, success if it does.
* @param {string} filename - The workflow filename to validate
* Populates the checkpoint <select> from ComfyUI API.
* Falls back to a text input if ComfyUI is unreachable.
*/
async function validateWorkflow(filename) {
if (!filename || !filename.trim()) return;
try {
const response = await fetch(`/${EXTENSION_FOLDER}/workflows/${filename.trim()}`, { method: "HEAD" });
if (response.ok) {
toastr.success(`Workflow "${filename}" found!`, "ComfyInject");
} else {
toastr.error(`Workflow "${filename}" not found in the workflows folder.`, "ComfyInject");
async function populateCheckpoints() {
const select = $("#comfyinject_checkpoint");
const current = getSettings().checkpoint;
const checkpoints = await fetchCheckpoints();

if (checkpoints.length > 0) {
select.empty();
for (const name of checkpoints) {
select.append(`<option value="${name}" ${name === current ? "selected" : ""}>${name}</option>`);
}
if (!checkpoints.includes(current) && current) {
select.append(`<option value="${current}" selected>${current}</option>`);
}
} catch (err) {
toastr.error(`Could not check workflow file.`, "ComfyInject");
} else {
// Fallback: show current value as editable text
select.empty();
select.append(`<option value="${current || ''}" selected>${current || '-- ComfyUI unreachable --'}</option>`);
}
}

/**
* Fetches checkpoints from ComfyUI and populates the dropdown.
* Called on init and when the arrow button is clicked.
* @param {boolean} [showToast=true] - Whether to show a toast notification
* Fetches the workflow index and populates the <select>.
*/
async function refreshCheckpointList(showToast = true) {
const checkpoints = await fetchCheckpoints();
const dropdown = $("#comfyinject_checkpoint_dropdown");
dropdown.empty();
async function populateWorkflows() {
const select = $("#comfyinject_workflow");
const current = getSettings().workflow;

if (checkpoints.length > 0) {
const current = getSettings().checkpoint;
for (const name of checkpoints) {
dropdown.append(
`<div class="comfyinject-checkpoint-option" data-value="${name}" style="padding: 6px 10px; cursor: pointer; ${name === current ? "font-weight: bold;" : ""}">${name}</div>`
);
try {
const response = await fetch(`/${EXTENSION_FOLDER}/workflows/index.json`);
const workflows = await response.json();
select.empty();
for (const name of workflows) {
select.append(`<option value="${name}" ${name === current ? "selected" : ""}>${name}</option>`);
}
if (showToast) {
toastr.success(`Found ${checkpoints.length} checkpoint(s)`, "ComfyInject");
if (!workflows.includes(current) && current) {
select.append(`<option value="${current}" selected>${current}</option>`);
}
} else if (showToast) {
toastr.warning("Could not reach ComfyUI. Is it running?", "ComfyInject");
} catch {
select.empty();
select.append(`<option value="${current || ''}" selected>${current || '-- No workflows found --'}</option>`);
}
}

Expand Down Expand Up @@ -220,61 +223,16 @@ function wireEvents() {
saveSettings();
});

// Checkpoint — text input
$("#comfyinject_checkpoint").on("input", function () {
// Checkpoint — select
$("#comfyinject_checkpoint").on("change", function () {
getSettings().checkpoint = $(this).val();
saveSettings();
});

// Checkpoint — arrow button toggles dropdown
$("#comfyinject_checkpoint_arrow").on("click", function () {
const dropdown = $("#comfyinject_checkpoint_dropdown");
if (dropdown.children().length === 0) {
// No checkpoints fetched yet — trigger a fetch
refreshCheckpointList(true).then(() => {
if ($("#comfyinject_checkpoint_dropdown").children().length > 0) {
dropdown.show();
}
});
} else {
dropdown.toggle();
}
});

// Checkpoint — clicking an option fills the text input and closes the dropdown
$("#comfyinject_checkpoint_dropdown").on("click", ".comfyinject-checkpoint-option", function () {
const value = $(this).data("value");
$("#comfyinject_checkpoint").val(value);
getSettings().checkpoint = value;
saveSettings();
$("#comfyinject_checkpoint_dropdown").hide();
});

// Checkpoint — hover highlight
$("#comfyinject_checkpoint_dropdown").on("mouseenter", ".comfyinject-checkpoint-option", function () {
$(this).css("background", "var(--SmartThemeQuoteColor)");
}).on("mouseleave", ".comfyinject-checkpoint-option", function () {
$(this).css("background", "");
});

// Close dropdown when clicking outside
$(document).on("click", function (e) {
if (!$(e.target).closest("#comfyinject_checkpoint_arrow, #comfyinject_checkpoint_dropdown").length) {
$("#comfyinject_checkpoint_dropdown").hide();
}
});

// Workflow — debounced validation after typing stops
let workflowValidateTimer = null;
$("#comfyinject_workflow").on("input", function () {
// Workflow — select
$("#comfyinject_workflow").on("change", function () {
getSettings().workflow = $(this).val();
saveSettings();

// Debounce — validate 1.5s after the user stops typing
clearTimeout(workflowValidateTimer);
workflowValidateTimer = setTimeout(() => {
validateWorkflow($(this).val());
}, 1500);
});

// Negative prompt
Expand Down Expand Up @@ -470,6 +428,7 @@ export async function initUI() {
populateUI();
wireEvents();

// Silently try to populate the checkpoint list on load — no toast if ComfyUI isn't running
refreshCheckpointList(false);
// Populate checkpoint and workflow dropdowns
populateCheckpoints();
populateWorkflows();
}
Loading