Merge pull request #70 from munimunigamer/auto-image-generation
Add automatic avatar generation for NPCs with LLM-powered prompts
This commit is contained in:
@@ -36,7 +36,8 @@ import {
|
|||||||
setInfoBoxContainer,
|
setInfoBoxContainer,
|
||||||
setThoughtsContainer,
|
setThoughtsContainer,
|
||||||
setInventoryContainer,
|
setInventoryContainer,
|
||||||
setQuestsContainer
|
setQuestsContainer,
|
||||||
|
clearSessionAvatarPrompts
|
||||||
} from './src/core/state.js';
|
} from './src/core/state.js';
|
||||||
import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js';
|
import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js';
|
||||||
import { registerAllEvents } from './src/core/events.js';
|
import { registerAllEvents } from './src/core/events.js';
|
||||||
@@ -407,6 +408,23 @@ async function initUI() {
|
|||||||
toggleAnimations();
|
toggleAnimations();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto avatar generation settings
|
||||||
|
$('#rpg-toggle-auto-avatars').on('change', function() {
|
||||||
|
extensionSettings.autoGenerateAvatars = $(this).prop('checked');
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
|
// Show/hide avatar options based on toggle
|
||||||
|
const $options = $('#rpg-avatar-options');
|
||||||
|
if (extensionSettings.autoGenerateAvatars) {
|
||||||
|
$options.slideDown(200);
|
||||||
|
} else {
|
||||||
|
$options.slideUp(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#rpg-avatar-llm-instruction').on('input', function() {
|
||||||
|
extensionSettings.avatarLLMCustomInstruction = $(this).val().trim();
|
||||||
|
saveSettings();
|
||||||
$('#rpg-toggle-dice-display').on('change', function() {
|
$('#rpg-toggle-dice-display').on('change', function() {
|
||||||
extensionSettings.showDiceDisplay = $(this).prop('checked');
|
extensionSettings.showDiceDisplay = $(this).prop('checked');
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -505,6 +523,18 @@ async function initUI() {
|
|||||||
|
|
||||||
$('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); $('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); $('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons);
|
$('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); $('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); $('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons);
|
||||||
$('#rpg-toggle-animations').prop('checked', extensionSettings.enableAnimations);
|
$('#rpg-toggle-animations').prop('checked', extensionSettings.enableAnimations);
|
||||||
|
|
||||||
|
// Initialize avatar options
|
||||||
|
$('#rpg-toggle-auto-avatars').prop('checked', extensionSettings.autoGenerateAvatars || false);
|
||||||
|
$('#rpg-avatar-llm-instruction').val(extensionSettings.avatarLLMCustomInstruction || '');
|
||||||
|
|
||||||
|
// Initialize avatar options visibility
|
||||||
|
if (extensionSettings.autoGenerateAvatars) {
|
||||||
|
$('#rpg-avatar-options').show();
|
||||||
|
} else {
|
||||||
|
$('#rpg-avatar-options').hide();
|
||||||
|
}
|
||||||
|
|
||||||
$('#rpg-toggle-dice-display').prop('checked', extensionSettings.showDiceDisplay);
|
$('#rpg-toggle-dice-display').prop('checked', extensionSettings.showDiceDisplay);
|
||||||
$('#rpg-stat-bar-color-low').val(extensionSettings.statBarColorLow);
|
$('#rpg-stat-bar-color-low').val(extensionSettings.statBarColorLow);
|
||||||
$('#rpg-stat-bar-color-high').val(extensionSettings.statBarColorHigh);
|
$('#rpg-stat-bar-color-high').val(extensionSettings.statBarColorHigh);
|
||||||
@@ -727,7 +757,7 @@ jQuery(async () => {
|
|||||||
[event_types.MESSAGE_RECEIVED]: onMessageReceived,
|
[event_types.MESSAGE_RECEIVED]: onMessageReceived,
|
||||||
[event_types.GENERATION_STOPPED]: onGenerationEnded,
|
[event_types.GENERATION_STOPPED]: onGenerationEnded,
|
||||||
[event_types.GENERATION_ENDED]: onGenerationEnded,
|
[event_types.GENERATION_ENDED]: onGenerationEnded,
|
||||||
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad],
|
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts],
|
||||||
[event_types.MESSAGE_SWIPED]: onMessageSwiped,
|
[event_types.MESSAGE_SWIPED]: onMessageSwiped,
|
||||||
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
|
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
|
||||||
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar
|
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar
|
||||||
|
|||||||
+23
-1
@@ -168,7 +168,10 @@ export let extensionSettings = {
|
|||||||
},
|
},
|
||||||
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
|
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
|
||||||
memoryMessagesToProcess: 16, // Number of messages to process per batch in memory recollection
|
memoryMessagesToProcess: 16, // Number of messages to process per batch in memory recollection
|
||||||
npcAvatars: {} // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
|
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
|
||||||
|
// Auto avatar generation settings
|
||||||
|
autoGenerateAvatars: false, // Master toggle for auto-generating avatars
|
||||||
|
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,6 +194,25 @@ export let committedTrackerData = {
|
|||||||
characterThoughts: null
|
characterThoughts: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-only storage for LLM-generated avatar prompts
|
||||||
|
* Maps character names to their generated prompts
|
||||||
|
* Resets on new chat (not persisted to extensionSettings)
|
||||||
|
*/
|
||||||
|
export let sessionAvatarPrompts = {};
|
||||||
|
|
||||||
|
export function setSessionAvatarPrompt(characterName, prompt) {
|
||||||
|
sessionAvatarPrompts[characterName] = prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionAvatarPrompt(characterName) {
|
||||||
|
return sessionAvatarPrompts[characterName] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionAvatarPrompts() {
|
||||||
|
sessionAvatarPrompts = {};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks whether the last action was a swipe (for separate mode)
|
* Tracks whether the last action was a swipe (for separate mode)
|
||||||
* Used to determine whether to commit lastGeneratedData to committedTrackerData
|
* Used to determine whether to commit lastGeneratedData to committedTrackerData
|
||||||
|
|||||||
@@ -45,6 +45,10 @@
|
|||||||
"template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.",
|
"template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.",
|
||||||
"template.settingsModal.display.enableDebugMode": "Enable Debug Mode",
|
"template.settingsModal.display.enableDebugMode": "Enable Debug Mode",
|
||||||
"template.settingsModal.display.enableDebugModeNote": "Shows parser logs in a mobile-friendly UI panel. Useful for troubleshooting. Look for the red bug button.",
|
"template.settingsModal.display.enableDebugModeNote": "Shows parser logs in a mobile-friendly UI panel. Useful for troubleshooting. Look for the red bug button.",
|
||||||
|
"template.settingsModal.display.autoGenerateAvatars": "Auto-generate Missing Avatars",
|
||||||
|
"template.settingsModal.display.autoGenerateAvatarsNote": "Automatically generate avatars for characters without custom images using the Image Generation Plugin",
|
||||||
|
"template.settingsModal.display.avatarLLMInstruction": "LLM Instruction:",
|
||||||
|
"template.settingsModal.display.avatarLLMInstructionNote": "The LLM will use character cards, tracker data, and chat context to generate detailed prompts",
|
||||||
"template.settingsModal.advancedTitle": "Advanced",
|
"template.settingsModal.advancedTitle": "Advanced",
|
||||||
"template.settingsModal.advanced.generationMode": "Generation Mode:",
|
"template.settingsModal.advanced.generationMode": "Generation Mode:",
|
||||||
"template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation",
|
"template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation",
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* Avatar Generator Module
|
||||||
|
* Handles automatic and manual avatar generation for NPC characters
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Batch generation with awaitable completion
|
||||||
|
* - Batch prompt generation via LLM
|
||||||
|
* - Individual image generation via /sd command
|
||||||
|
* - Manual regeneration support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateRaw, characters, this_chid } from '../../../../../../../script.js';
|
||||||
|
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
|
||||||
|
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
|
||||||
|
import { extensionSettings, sessionAvatarPrompts, setSessionAvatarPrompt } from '../../core/state.js';
|
||||||
|
import { saveSettings } from '../../core/persistence.js';
|
||||||
|
import { generateAvatarPromptGenerationPrompt, parseAvatarPromptsResponse } from '../generation/promptBuilder.js';
|
||||||
|
import { getCurrentPresetName, switchToPreset } from '../generation/apiClient.js';
|
||||||
|
|
||||||
|
// Generation state - tracks characters currently being generated
|
||||||
|
const pendingGenerations = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a character is pending generation (waiting or actively generating)
|
||||||
|
* @param {string} characterName - Name of character to check
|
||||||
|
* @returns {boolean} True if generation is pending
|
||||||
|
*/
|
||||||
|
export function isGenerating(characterName) {
|
||||||
|
return pendingGenerations.has(characterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if any avatars are currently being generated
|
||||||
|
* @returns {boolean} True if any generation is in progress
|
||||||
|
*/
|
||||||
|
export function isAnyGenerating() {
|
||||||
|
return pendingGenerations.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all characters currently pending generation
|
||||||
|
* @returns {string[]} Array of character names
|
||||||
|
*/
|
||||||
|
export function getPendingGenerations() {
|
||||||
|
return [...pendingGenerations];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if two character names match (case-insensitive, handles partial matches)
|
||||||
|
* @param {string} cardName - Name from character card
|
||||||
|
* @param {string} aiName - Name from AI response
|
||||||
|
* @returns {boolean} True if names match
|
||||||
|
*/
|
||||||
|
function namesMatch(cardName, aiName) {
|
||||||
|
if (!cardName || !aiName) return false;
|
||||||
|
const cardLower = cardName.toLowerCase().trim();
|
||||||
|
const aiLower = aiName.toLowerCase().trim();
|
||||||
|
if (cardLower === aiLower) return true;
|
||||||
|
const cardCore = cardLower.split(/[\s,'"]+/)[0];
|
||||||
|
const aiCore = aiLower.split(/[\s,'"]+/)[0];
|
||||||
|
if (cardCore === aiCore) return true;
|
||||||
|
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
|
||||||
|
return wordBoundary.test(aiCore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a character already has an avatar (custom NPC avatar or from character card)
|
||||||
|
* @param {string} characterName - Name of character to check
|
||||||
|
* @returns {boolean} True if character has an avatar
|
||||||
|
*/
|
||||||
|
export function hasExistingAvatar(characterName) {
|
||||||
|
// Check for custom NPC avatar first
|
||||||
|
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
|
||||||
|
const avatar = extensionSettings.npcAvatars[characterName];
|
||||||
|
if (typeof avatar === 'string' && avatar) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check group members for avatar
|
||||||
|
if (selected_group) {
|
||||||
|
try {
|
||||||
|
const groupMembers = getGroupMembers(selected_group);
|
||||||
|
if (groupMembers && groupMembers.length > 0) {
|
||||||
|
const matchingMember = groupMembers.find(member =>
|
||||||
|
member && member.name && namesMatch(member.name, characterName)
|
||||||
|
);
|
||||||
|
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all characters for avatar
|
||||||
|
if (characters && characters.length > 0) {
|
||||||
|
const matchingCharacter = characters.find(c =>
|
||||||
|
c && c.name && namesMatch(c.name, characterName)
|
||||||
|
);
|
||||||
|
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current character in 1-on-1 chat
|
||||||
|
if (this_chid !== undefined && characters[this_chid] &&
|
||||||
|
characters[this_chid].name && namesMatch(characters[this_chid].name, characterName)) {
|
||||||
|
if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates avatars for multiple characters and waits for all to complete.
|
||||||
|
* This is the main entry point for auto-generation within a workflow.
|
||||||
|
*
|
||||||
|
* @param {string[]} characterNames - Array of character names to generate avatars for
|
||||||
|
* @param {Function} onStarted - Optional callback when generation starts (to update UI)
|
||||||
|
* @returns {Promise<void>} Resolves when all generations complete
|
||||||
|
*/
|
||||||
|
export async function generateAvatarsForCharacters(characterNames, onStarted = null) {
|
||||||
|
if (!extensionSettings.autoGenerateAvatars) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to characters that need avatars
|
||||||
|
const needsGeneration = characterNames.filter(name => {
|
||||||
|
// Skip if already pending
|
||||||
|
if (pendingGenerations.has(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Skip if has avatar
|
||||||
|
if (hasExistingAvatar(name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsGeneration.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[RPG Avatar] Starting batch generation for:', needsGeneration);
|
||||||
|
|
||||||
|
// Mark all as pending IMMEDIATELY (before any async work)
|
||||||
|
for (const name of needsGeneration) {
|
||||||
|
pendingGenerations.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger UI update to show loading spinners
|
||||||
|
if (onStarted) {
|
||||||
|
try {
|
||||||
|
onStarted([...needsGeneration]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[RPG Avatar] Error in onStarted callback:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate LLM prompts for all characters that don't have them
|
||||||
|
const needsPrompts = needsGeneration.filter(name => !sessionAvatarPrompts[name]);
|
||||||
|
if (needsPrompts.length > 0) {
|
||||||
|
await generateLLMPrompts(needsPrompts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate images one at a time
|
||||||
|
for (const characterName of needsGeneration) {
|
||||||
|
// Skip if somehow already has avatar now
|
||||||
|
if (hasExistingAvatar(characterName)) {
|
||||||
|
pendingGenerations.delete(characterName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateSingleAvatar(characterName);
|
||||||
|
pendingGenerations.delete(characterName);
|
||||||
|
|
||||||
|
// Small delay between generations to avoid overwhelming the API
|
||||||
|
if (needsGeneration.indexOf(characterName) < needsGeneration.length - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Ensure all are removed from pending even if there's an error
|
||||||
|
for (const name of needsGeneration) {
|
||||||
|
pendingGenerations.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[RPG Avatar] Batch generation complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerates avatar for a specific character
|
||||||
|
* Clears existing avatar and prompt, then generates new ones
|
||||||
|
* Handles preset switching if useSeparatePreset is enabled
|
||||||
|
*
|
||||||
|
* @param {string} characterName - Name of character to regenerate
|
||||||
|
* @returns {Promise<string|null>} New avatar URL or null if failed
|
||||||
|
*/
|
||||||
|
export async function regenerateAvatar(characterName) {
|
||||||
|
console.log('[RPG Avatar] Regenerating avatar for:', characterName);
|
||||||
|
|
||||||
|
// Mark as pending immediately
|
||||||
|
pendingGenerations.add(characterName);
|
||||||
|
|
||||||
|
// Clear existing avatar
|
||||||
|
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
|
||||||
|
delete extensionSettings.npcAvatars[characterName];
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing prompt to force new LLM generation
|
||||||
|
if (sessionAvatarPrompts[characterName]) {
|
||||||
|
delete sessionAvatarPrompts[characterName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current preset and switch to RPG Companion Trackers if enabled
|
||||||
|
let originalPresetName = null;
|
||||||
|
if (extensionSettings.useSeparatePreset) {
|
||||||
|
originalPresetName = await getCurrentPresetName();
|
||||||
|
if (originalPresetName) {
|
||||||
|
console.log(`[RPG Avatar] Switching from "${originalPresetName}" to RPG Companion Trackers preset`);
|
||||||
|
await switchToPreset('RPG Companion Trackers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate new LLM prompt
|
||||||
|
await generateLLMPrompts([characterName]);
|
||||||
|
|
||||||
|
// Generate the avatar
|
||||||
|
return await generateSingleAvatar(characterName);
|
||||||
|
} finally {
|
||||||
|
// Restore original preset if we switched
|
||||||
|
if (originalPresetName && extensionSettings.useSeparatePreset) {
|
||||||
|
console.log(`[RPG Avatar] Restoring original preset: "${originalPresetName}"`);
|
||||||
|
await switchToPreset(originalPresetName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from pending when done
|
||||||
|
pendingGenerations.delete(characterName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates LLM prompts for multiple characters in a single API call
|
||||||
|
*
|
||||||
|
* @param {string[]} characterNames - Names of characters needing prompts
|
||||||
|
*/
|
||||||
|
async function generateLLMPrompts(characterNames) {
|
||||||
|
if (characterNames.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[RPG Avatar] Generating LLM prompts for:', characterNames);
|
||||||
|
|
||||||
|
const promptMessages = await generateAvatarPromptGenerationPrompt(characterNames);
|
||||||
|
const response = await generateRaw({
|
||||||
|
prompt: promptMessages,
|
||||||
|
quietToLoud: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
const prompts = parseAvatarPromptsResponse(response);
|
||||||
|
console.log('[RPG Avatar] Generated prompts:', prompts);
|
||||||
|
|
||||||
|
// Store prompts in session storage
|
||||||
|
for (const [name, prompt] of Object.entries(prompts)) {
|
||||||
|
setSessionAvatarPrompt(name, prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RPG Avatar] Failed to generate LLM prompts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a fallback prompt when LLM prompt generation fails or isn't available
|
||||||
|
* Uses information embedded in the character name if present (e.g., from malformed tracker output)
|
||||||
|
*
|
||||||
|
* @param {string} characterName - Character name (may contain additional details)
|
||||||
|
* @returns {string} A basic prompt for image generation
|
||||||
|
*/
|
||||||
|
function buildFallbackPrompt(characterName) {
|
||||||
|
// Check if the name contains embedded details (malformed format from weaker models)
|
||||||
|
// e.g., "Eris Details: 🌟 | beautiful girl with white hair | kind expression"
|
||||||
|
if (characterName.includes('Details:') || characterName.includes('|')) {
|
||||||
|
// Extract useful description parts
|
||||||
|
const parts = characterName.split(/Details:|[|]/).map(p => p.trim()).filter(p => p && !p.match(/^[\p{Emoji}]+$/u));
|
||||||
|
if (parts.length > 1) {
|
||||||
|
// First part is likely the name, rest are descriptions
|
||||||
|
const name = parts[0];
|
||||||
|
const descriptions = parts.slice(1).join(', ');
|
||||||
|
return `portrait of ${name}, ${descriptions}, fantasy art style, detailed`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple fallback - just use the name
|
||||||
|
return `portrait of ${characterName}, character portrait, fantasy art style, detailed face, high quality`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a single avatar using the /sd command
|
||||||
|
*
|
||||||
|
* @param {string} characterName - Name of character to generate avatar for
|
||||||
|
* @returns {Promise<string|null>} Avatar URL or null if failed
|
||||||
|
*/
|
||||||
|
async function generateSingleAvatar(characterName) {
|
||||||
|
// Get the prompt from session storage, or build a fallback
|
||||||
|
let prompt = sessionAvatarPrompts[characterName];
|
||||||
|
if (!prompt) {
|
||||||
|
console.log(`[RPG Avatar] No LLM prompt for ${characterName}, using fallback prompt`);
|
||||||
|
prompt = buildFallbackPrompt(characterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RPG Avatar] Starting image generation for: ${characterName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute /sd command with quiet=true to suppress chat output
|
||||||
|
const result = await executeSlashCommandsOnChatInput(
|
||||||
|
`/sd quiet=true ${prompt}`,
|
||||||
|
{ clearChatInput: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract image URL from result
|
||||||
|
const imageUrl = extractImageUrl(result);
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
// Store the avatar
|
||||||
|
if (!extensionSettings.npcAvatars) {
|
||||||
|
extensionSettings.npcAvatars = {};
|
||||||
|
}
|
||||||
|
extensionSettings.npcAvatars[characterName] = imageUrl;
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
|
console.log(`[RPG Avatar] Successfully generated avatar for: ${characterName}`);
|
||||||
|
return imageUrl;
|
||||||
|
} else {
|
||||||
|
console.warn(`[RPG Avatar] Failed to extract image URL for ${characterName}:`, result);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[RPG Avatar] Generation failed for ${characterName}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts image URL from /sd command result
|
||||||
|
* Handles various result formats
|
||||||
|
*
|
||||||
|
* @param {any} result - Result from executeSlashCommandsOnChatInput
|
||||||
|
* @returns {string|null} Image URL or null
|
||||||
|
*/
|
||||||
|
function extractImageUrl(result) {
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
// Handle string result
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
// Validate it looks like a URL or data URI
|
||||||
|
if (result.startsWith('http') || result.startsWith('data:') || result.startsWith('/')) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle object result with various possible properties
|
||||||
|
if (typeof result === 'object') {
|
||||||
|
// Try common properties
|
||||||
|
const url = result.pipe || result.output || result.image || result.url || result.result;
|
||||||
|
|
||||||
|
if (url && typeof url === 'string') {
|
||||||
|
if (url.startsWith('http') || url.startsWith('data:') || url.startsWith('/')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all pending generations and resets state
|
||||||
|
*/
|
||||||
|
export function clearPendingGenerations() {
|
||||||
|
pendingGenerations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current generation status for display
|
||||||
|
* @returns {{pending: number, names: string[]}}
|
||||||
|
*/
|
||||||
|
export function getGenerationStatus() {
|
||||||
|
return {
|
||||||
|
pending: pendingGenerations.size,
|
||||||
|
names: [...pendingGenerations]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
setLastActionWasSwipe
|
setLastActionWasSwipe
|
||||||
} from '../../core/state.js';
|
} from '../../core/state.js';
|
||||||
import { saveChatData } from '../../core/persistence.js';
|
import { saveChatData } from '../../core/persistence.js';
|
||||||
import { generateSeparateUpdatePrompt } from './promptBuilder.js';
|
import {
|
||||||
|
generateSeparateUpdatePrompt
|
||||||
|
} from './promptBuilder.js';
|
||||||
import { parseResponse, parseUserStats } from './parser.js';
|
import { parseResponse, parseUserStats } from './parser.js';
|
||||||
import { renderUserStats } from '../rendering/userStats.js';
|
import { renderUserStats } from '../rendering/userStats.js';
|
||||||
import { renderInfoBox } from '../rendering/infoBox.js';
|
import { renderInfoBox } from '../rendering/infoBox.js';
|
||||||
@@ -23,6 +25,7 @@ import { renderThoughts } from '../rendering/thoughts.js';
|
|||||||
import { renderInventory } from '../rendering/inventory.js';
|
import { renderInventory } from '../rendering/inventory.js';
|
||||||
import { renderQuests } from '../rendering/quests.js';
|
import { renderQuests } from '../rendering/quests.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { generateAvatarsForCharacters } from '../features/avatarGenerator.js';
|
||||||
|
|
||||||
// Store the original preset name to restore after tracker generation
|
// Store the original preset name to restore after tracker generation
|
||||||
let originalPresetName = null;
|
let originalPresetName = null;
|
||||||
@@ -31,7 +34,7 @@ let originalPresetName = null;
|
|||||||
* Gets the current preset name using the /preset command
|
* Gets the current preset name using the /preset command
|
||||||
* @returns {Promise<string|null>} Current preset name or null if unavailable
|
* @returns {Promise<string|null>} Current preset name or null if unavailable
|
||||||
*/
|
*/
|
||||||
async function getCurrentPresetName() {
|
export async function getCurrentPresetName() {
|
||||||
try {
|
try {
|
||||||
// Use /preset without arguments to get the current preset name
|
// Use /preset without arguments to get the current preset name
|
||||||
const result = await executeSlashCommandsOnChatInput('/preset', { quiet: true });
|
const result = await executeSlashCommandsOnChatInput('/preset', { quiet: true });
|
||||||
@@ -55,12 +58,14 @@ async function getCurrentPresetName() {
|
|||||||
console.error('[RPG Companion] Error getting current preset:', error);
|
console.error('[RPG Companion] Error getting current preset:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}/**
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Switches to a specific preset by name using the /preset slash command
|
* Switches to a specific preset by name using the /preset slash command
|
||||||
* @param {string} presetName - Name of the preset to switch to
|
* @param {string} presetName - Name of the preset to switch to
|
||||||
* @returns {Promise<boolean>} True if switching succeeded, false otherwise
|
* @returns {Promise<boolean>} True if switching succeeded, false otherwise
|
||||||
*/
|
*/
|
||||||
async function switchToPreset(presetName) {
|
export async function switchToPreset(presetName) {
|
||||||
try {
|
try {
|
||||||
// Use the /preset slash command to switch presets
|
// Use the /preset slash command to switch presets
|
||||||
// This is the proper way to change presets in SillyTavern
|
// This is the proper way to change presets in SillyTavern
|
||||||
@@ -207,7 +212,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
|||||||
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
|
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the updated data (outside the message check, always render)
|
// Render the updated data
|
||||||
renderUserStats();
|
renderUserStats();
|
||||||
renderInfoBox();
|
renderInfoBox();
|
||||||
renderThoughts();
|
renderThoughts();
|
||||||
@@ -216,6 +221,26 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
|||||||
|
|
||||||
// Save to chat metadata
|
// Save to chat metadata
|
||||||
saveChatData();
|
saveChatData();
|
||||||
|
|
||||||
|
// Generate avatars if auto-generate is enabled (runs within this workflow)
|
||||||
|
// This uses the RPG Companion Trackers preset and keeps the button spinning
|
||||||
|
if (extensionSettings.autoGenerateAvatars) {
|
||||||
|
const charactersNeedingAvatars = parseCharactersFromThoughts(parsedData.characterThoughts);
|
||||||
|
if (charactersNeedingAvatars.length > 0) {
|
||||||
|
console.log('[RPG Companion] Generating avatars for:', charactersNeedingAvatars);
|
||||||
|
|
||||||
|
// Generate avatars - this awaits completion
|
||||||
|
await generateAvatarsForCharacters(charactersNeedingAvatars, (names) => {
|
||||||
|
// Callback when generation starts - re-render to show loading spinners
|
||||||
|
console.log('[RPG Companion] Avatar generation started, showing spinners...');
|
||||||
|
renderThoughts();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-render once all avatars are generated
|
||||||
|
console.log('[RPG Companion] All avatars generated, re-rendering...');
|
||||||
|
renderThoughts();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -241,3 +266,25 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
|||||||
setLastActionWasSwipe(false);
|
setLastActionWasSwipe(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses character names from Present Characters thoughts data
|
||||||
|
* @param {string} characterThoughtsData - Raw character thoughts data
|
||||||
|
* @returns {Array<string>} Array of character names found
|
||||||
|
*/
|
||||||
|
function parseCharactersFromThoughts(characterThoughtsData) {
|
||||||
|
if (!characterThoughtsData) return [];
|
||||||
|
|
||||||
|
const lines = characterThoughtsData.split('\n');
|
||||||
|
const characters = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim().startsWith('- ')) {
|
||||||
|
const name = line.trim().substring(2).trim();
|
||||||
|
if (name && name.toLowerCase() !== 'unavailable') {
|
||||||
|
characters.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return characters;
|
||||||
|
}
|
||||||
|
|||||||
@@ -626,3 +626,85 @@ export async function generateSeparateUpdatePrompt() {
|
|||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default custom instruction for avatar prompt generation
|
||||||
|
*/
|
||||||
|
const DEFAULT_AVATAR_CUSTOM_INSTRUCTION = `Create a detailed portrait prompt focusing on the character's appearance, clothing, and mood. Include appropriate artistic style keywords.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the prompt for LLM-based avatar prompt generation
|
||||||
|
* Uses the same context as RPG generation (character cards, tracker data, chat history)
|
||||||
|
*
|
||||||
|
* @param {Array<string>} characterNames - Array of character names to generate prompts for
|
||||||
|
* @returns {Promise<Array<{role: string, content: string}>>} Message array for generateRaw API
|
||||||
|
*/
|
||||||
|
export async function generateAvatarPromptGenerationPrompt(characterNames) {
|
||||||
|
const depth = extensionSettings.updateDepth;
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
// Build system message with character context
|
||||||
|
let systemMessage = `You are an AI assistant specializing in creating detailed image generation prompts for character avatars.\n\n`;
|
||||||
|
|
||||||
|
// Add character card information (reusing existing function)
|
||||||
|
const characterInfo = await getCharacterCardsInfo();
|
||||||
|
if (characterInfo) {
|
||||||
|
systemMessage += `Character Information:\n${characterInfo}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracker context if available
|
||||||
|
if (committedTrackerData.characterThoughts) {
|
||||||
|
systemMessage += `Current Scene Context:\n${committedTrackerData.characterThoughts}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
systemMessage += `Recent conversation context:\n<history>`;
|
||||||
|
messages.push({ role: 'system', content: systemMessage });
|
||||||
|
|
||||||
|
// Add chat history
|
||||||
|
const recentMessages = chat.slice(-depth);
|
||||||
|
for (const message of recentMessages) {
|
||||||
|
messages.push({
|
||||||
|
role: message.is_user ? 'user' : 'assistant',
|
||||||
|
content: message.mes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build instruction message
|
||||||
|
let instructionMessage = `</history>\n\n`;
|
||||||
|
const customInstruction = extensionSettings.avatarLLMCustomInstruction || DEFAULT_AVATAR_CUSTOM_INSTRUCTION;
|
||||||
|
|
||||||
|
instructionMessage += `Task: Generate detailed image prompts for the following characters.\n\n`;
|
||||||
|
instructionMessage += `Instructions: ${customInstruction}\n\n`;
|
||||||
|
instructionMessage += `Characters:\n`;
|
||||||
|
characterNames.forEach((name, index) => {
|
||||||
|
instructionMessage += `${index + 1}. ${name}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
instructionMessage += `\nOutput Format (one per line):\n`;
|
||||||
|
instructionMessage += `CHARACTER_NAME: [detailed prompt]\n\n`;
|
||||||
|
instructionMessage += `Example:\n`;
|
||||||
|
instructionMessage += `Gandalf: portrait, elderly wizard with long white beard, wearing grey robes, holding wooden staff, intense blue eyes, wise expression, fantasy art style\n\n`;
|
||||||
|
instructionMessage += `Provide ONLY the formatted prompts, no other text.`;
|
||||||
|
|
||||||
|
messages.push({ role: 'user', content: instructionMessage });
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses LLM response to extract character prompts
|
||||||
|
* @param {string} response - Raw LLM response
|
||||||
|
* @returns {Object} Map of character name to prompt
|
||||||
|
*/
|
||||||
|
export function parseAvatarPromptsResponse(response) {
|
||||||
|
const prompts = {};
|
||||||
|
const lines = response.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
const match = trimmed.match(/^([^:]+):\s*(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
prompts[match[1].trim()] = match[2].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prompts;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { saveChatData } from '../../core/persistence.js';
|
import { saveChatData } from '../../core/persistence.js';
|
||||||
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
|
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
|
||||||
import { saveSettings } from '../../core/persistence.js';
|
import { saveSettings } from '../../core/persistence.js';
|
||||||
|
import { isGenerating, regenerateAvatar } from '../features/avatarGenerator.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to log to both console and debug logs array
|
* Helper to log to both console and debug logs array
|
||||||
@@ -110,12 +111,21 @@ function namesMatch(cardName, aiName) {
|
|||||||
function getCharacterAvatar(characterName) {
|
function getCharacterAvatar(characterName) {
|
||||||
// First, check if there's a custom NPC avatar
|
// First, check if there's a custom NPC avatar
|
||||||
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
|
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
|
||||||
debugLog(`[RPG Thoughts] Found custom NPC avatar for: ${characterName}`);
|
const avatar = extensionSettings.npcAvatars[characterName];
|
||||||
return extensionSettings.npcAvatars[characterName];
|
// Skip if not a valid string (e.g., if it's an object from a previous bug)
|
||||||
|
if (typeof avatar === 'string' && avatar) {
|
||||||
|
debugLog(`[RPG Thoughts] Found custom NPC avatar for: ${characterName}`);
|
||||||
|
return avatar;
|
||||||
|
} else {
|
||||||
|
// Clear invalid avatar data
|
||||||
|
console.warn(`[RPG Thoughts] Invalid avatar data for ${characterName}, clearing...`);
|
||||||
|
delete extensionSettings.npcAvatars[characterName];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the existing avatar lookup logic
|
// Use the existing avatar lookup logic
|
||||||
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
|
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
|
||||||
|
let hasAvatar = false;
|
||||||
|
|
||||||
// For group chats, search through group members first
|
// For group chats, search through group members first
|
||||||
if (selected_group) {
|
if (selected_group) {
|
||||||
@@ -129,6 +139,7 @@ function getCharacterAvatar(characterName) {
|
|||||||
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
|
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
|
||||||
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
|
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
|
||||||
if (thumbnailUrl) {
|
if (thumbnailUrl) {
|
||||||
|
hasAvatar = true;
|
||||||
return thumbnailUrl;
|
return thumbnailUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +158,7 @@ function getCharacterAvatar(characterName) {
|
|||||||
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
|
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
|
||||||
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
|
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
|
||||||
if (thumbnailUrl) {
|
if (thumbnailUrl) {
|
||||||
|
hasAvatar = true;
|
||||||
return thumbnailUrl;
|
return thumbnailUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +169,7 @@ function getCharacterAvatar(characterName) {
|
|||||||
characters[this_chid].name && namesMatch(characters[this_chid].name, characterName)) {
|
characters[this_chid].name && namesMatch(characters[this_chid].name, characterName)) {
|
||||||
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
|
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
|
||||||
if (thumbnailUrl) {
|
if (thumbnailUrl) {
|
||||||
|
hasAvatar = true;
|
||||||
return thumbnailUrl;
|
return thumbnailUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -463,7 +476,7 @@ export function renderThoughts() {
|
|||||||
html += '<div class="rpg-thoughts-content">';
|
html += '<div class="rpg-thoughts-content">';
|
||||||
html += `
|
html += `
|
||||||
<div class="rpg-character-card" data-character-name="${escapedDefaultName}">
|
<div class="rpg-character-card" data-character-name="${escapedDefaultName}">
|
||||||
<div class="rpg-character-avatar rpg-avatar-upload" data-character="${escapedDefaultName}" title="Click to upload custom avatar Right-click to remove custom avatar">
|
<div class="rpg-character-avatar rpg-avatar-upload" data-character="${escapedDefaultName}" title="Click to upload custom avatar Right-click to regenerate avatar">
|
||||||
<img src="${defaultPortrait}" alt="${escapedDefaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
<img src="${defaultPortrait}" alt="${escapedDefaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||||
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">⚖️</div>
|
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">⚖️</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -500,7 +513,7 @@ export function renderThoughts() {
|
|||||||
// Find character portrait using the new helper function
|
// Find character portrait using the new helper function
|
||||||
const characterPortrait = getCharacterAvatar(char.name);
|
const characterPortrait = getCharacterAvatar(char.name);
|
||||||
|
|
||||||
debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...');
|
debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, typeof characterPortrait === 'string' ? characterPortrait.substring(0, 50) + '...' : characterPortrait);
|
||||||
|
|
||||||
// Get relationship badge - only if relationships are enabled in config
|
// Get relationship badge - only if relationships are enabled in config
|
||||||
let relationshipBadge = '⚖️'; // Default
|
let relationshipBadge = '⚖️'; // Default
|
||||||
@@ -519,10 +532,14 @@ export function renderThoughts() {
|
|||||||
// Escape character name for use in HTML attributes
|
// Escape character name for use in HTML attributes
|
||||||
const escapedName = escapeHtmlAttr(char.name);
|
const escapedName = escapeHtmlAttr(char.name);
|
||||||
|
|
||||||
|
// Check if avatar is being generated
|
||||||
|
const isCurrentlyGenerating = isGenerating(char.name);
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="rpg-character-card" data-character-name="${escapedName}">
|
<div class="rpg-character-card" data-character-name="${escapedName}">
|
||||||
<div class="rpg-character-avatar rpg-avatar-upload" data-character="${escapedName}" title="Click to upload custom avatar Right-click to remove custom avatar">
|
<div class="rpg-character-avatar rpg-avatar-upload ${isCurrentlyGenerating ? 'rpg-avatar-generating' : ''}" data-character="${escapedName}" title="Click to upload custom avatar Right-click to regenerate avatar">
|
||||||
<img src="${characterPortrait}" alt="${escapedName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
<img src="${characterPortrait}" alt="${escapedName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||||
|
${isCurrentlyGenerating ? '<div class="rpg-generating-overlay"><i class="fa-solid fa-spinner fa-spin"></i></div>' : ''}
|
||||||
${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${relationshipFieldName}" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipBadge}</div>` : ''}
|
${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${relationshipFieldName}" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipBadge}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="rpg-character-content">
|
<div class="rpg-character-content">
|
||||||
@@ -606,8 +623,8 @@ export function renderThoughts() {
|
|||||||
uploadNpcAvatar(characterName);
|
uploadNpcAvatar(characterName);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add event handler for removing custom avatars (right-click)
|
// Add event handler for regenerating avatars (right-click)
|
||||||
$thoughtsContainer.find('.rpg-avatar-upload').on('contextmenu', function(e) {
|
$thoughtsContainer.find('.rpg-avatar-upload').on('contextmenu', async function(e) {
|
||||||
// Prevent triggering if clicking on the relationship badge
|
// Prevent triggering if clicking on the relationship badge
|
||||||
if ($(e.target).hasClass('rpg-relationship-badge') || $(e.target).closest('.rpg-relationship-badge').length > 0) {
|
if ($(e.target).hasClass('rpg-relationship-badge') || $(e.target).closest('.rpg-relationship-badge').length > 0) {
|
||||||
return;
|
return;
|
||||||
@@ -615,17 +632,42 @@ export function renderThoughts() {
|
|||||||
|
|
||||||
e.preventDefault(); // Prevent default context menu
|
e.preventDefault(); // Prevent default context menu
|
||||||
const characterName = $(this).data('character');
|
const characterName = $(this).data('character');
|
||||||
|
const $avatarEl = $(this);
|
||||||
|
|
||||||
// Check if this character has a custom avatar
|
// Check if auto-generation is enabled
|
||||||
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
|
if (!extensionSettings.autoGenerateAvatars) {
|
||||||
// Remove the custom avatar
|
// If auto-generation is disabled, just remove the custom avatar
|
||||||
delete extensionSettings.npcAvatars[characterName];
|
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) {
|
||||||
saveSettings();
|
delete extensionSettings.npcAvatars[characterName];
|
||||||
console.log(`[RPG Companion] Removed custom avatar for: ${characterName}`);
|
saveSettings();
|
||||||
|
console.log(`[RPG Companion] Removed custom avatar for: ${characterName}`);
|
||||||
// Re-render to show the default avatar
|
renderThoughts();
|
||||||
renderThoughts();
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show generating state with spinner overlay
|
||||||
|
$avatarEl.addClass('rpg-avatar-generating');
|
||||||
|
if (!$avatarEl.find('.rpg-generating-overlay').length) {
|
||||||
|
$avatarEl.append('<div class="rpg-generating-overlay"><i class="fa-solid fa-spinner fa-spin"></i></div>');
|
||||||
|
}
|
||||||
|
console.log(`[RPG Companion] Regenerating avatar for: ${characterName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Regenerate the avatar
|
||||||
|
const newUrl = await regenerateAvatar(characterName);
|
||||||
|
|
||||||
|
if (newUrl) {
|
||||||
|
console.log(`[RPG Companion] Successfully regenerated avatar for: ${characterName}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`[RPG Companion] Failed to regenerate avatar for: ${characterName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[RPG Companion] Error regenerating avatar for ${characterName}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render to show the new avatar (or fallback)
|
||||||
|
renderThoughts();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add event handler for character removal
|
// Add event handler for character removal
|
||||||
|
|||||||
@@ -6767,3 +6767,37 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
background: linear-gradient(180deg, #4a9eff 0%, transparent 100%);
|
background: linear-gradient(180deg, #4a9eff 0%, transparent 100%);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Avatar generation loading overlay */
|
||||||
|
.rpg-avatar-generating {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-generating-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea style for LLM instruction input */
|
||||||
|
.rpg-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--rpg-border);
|
||||||
|
background: var(--SmartThemeBlurTintColor);
|
||||||
|
color: var(--SmartThemeBodyColor);
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|||||||
@@ -245,6 +245,27 @@
|
|||||||
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" data-i18n-key="template.settingsModal.display.enableDebugModeNote">
|
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" data-i18n-key="template.settingsModal.display.enableDebugModeNote">
|
||||||
Shows parser logs in a mobile-friendly UI panel. Useful for troubleshooting. Look for the red bug button.
|
Shows parser logs in a mobile-friendly UI panel. Useful for troubleshooting. Look for the red bug button.
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
|
<label class="checkbox_label">
|
||||||
|
<input type="checkbox" id="rpg-toggle-auto-avatars" />
|
||||||
|
<span data-i18n-key="template.settingsModal.display.autoGenerateAvatars">Auto-generate Missing Avatars</span>
|
||||||
|
</label>
|
||||||
|
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" data-i18n-key="template.settingsModal.display.autoGenerateAvatarsNote">
|
||||||
|
Automatically generate avatars for characters without custom images using the Image Generation Plugin
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<!-- Avatar options container - conditionally visible -->
|
||||||
|
<div id="rpg-avatar-options" style="margin-left: 24px; margin-top: 12px; display: none;">
|
||||||
|
<div class="rpg-setting-row" style="margin-top: 12px;">
|
||||||
|
<label for="rpg-avatar-llm-instruction" style="display: block; margin-bottom: 8px;" data-i18n-key="template.settingsModal.display.avatarLLMInstruction">
|
||||||
|
LLM Instruction:
|
||||||
|
</label>
|
||||||
|
<textarea id="rpg-avatar-llm-instruction" class="rpg-textarea" rows="3" placeholder="Create a detailed portrait prompt focusing on the character's appearance, clothing, and mood..."></textarea>
|
||||||
|
<small style="display: block; margin-top: 4px; color: #888; font-size: 11px;" data-i18n-key="template.settingsModal.display.avatarLLMInstructionNote">
|
||||||
|
The LLM will use character cards, tracker data, and chat context to generate detailed prompts
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rpg-settings-group">
|
<div class="rpg-settings-group">
|
||||||
|
|||||||
Reference in New Issue
Block a user