diff --git a/index.js b/index.js index e2edabc..afd3d80 100644 --- a/index.js +++ b/index.js @@ -36,7 +36,8 @@ import { setInfoBoxContainer, setThoughtsContainer, setInventoryContainer, - setQuestsContainer + setQuestsContainer, + clearSessionAvatarPrompts } from './src/core/state.js'; import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js'; import { registerAllEvents } from './src/core/events.js'; @@ -407,6 +408,23 @@ async function initUI() { 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() { extensionSettings.showDiceDisplay = $(this).prop('checked'); 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-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-stat-bar-color-low').val(extensionSettings.statBarColorLow); $('#rpg-stat-bar-color-high').val(extensionSettings.statBarColorHigh); @@ -727,7 +757,7 @@ jQuery(async () => { [event_types.MESSAGE_RECEIVED]: onMessageReceived, [event_types.GENERATION_STOPPED]: 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.USER_MESSAGE_RENDERED]: updatePersonaAvatar, [event_types.SETTINGS_UPDATED]: updatePersonaAvatar diff --git a/src/core/state.js b/src/core/state.js index 024e7f2..03fabf9 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -168,7 +168,10 @@ export let extensionSettings = { }, debugMode: false, // Enable debug logging visible in UI (for mobile debugging) 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 }; +/** + * 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) * Used to determine whether to commit lastGeneratedData to committedTrackerData diff --git a/src/i18n/en.json b/src/i18n/en.json index be4d0b7..f657e0e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -45,6 +45,10 @@ "template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.", "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.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.advanced.generationMode": "Generation Mode:", "template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation", diff --git a/src/systems/features/avatarGenerator.js b/src/systems/features/avatarGenerator.js new file mode 100644 index 0000000..b7f28df --- /dev/null +++ b/src/systems/features/avatarGenerator.js @@ -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} 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} 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} 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] + }; +} diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index c3e2c89..2c74834 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -15,7 +15,9 @@ import { setLastActionWasSwipe } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; -import { generateSeparateUpdatePrompt } from './promptBuilder.js'; +import { + generateSeparateUpdatePrompt +} from './promptBuilder.js'; import { parseResponse, parseUserStats } from './parser.js'; import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; @@ -23,6 +25,7 @@ import { renderThoughts } from '../rendering/thoughts.js'; import { renderInventory } from '../rendering/inventory.js'; import { renderQuests } from '../rendering/quests.js'; import { i18n } from '../../core/i18n.js'; +import { generateAvatarsForCharacters } from '../features/avatarGenerator.js'; // Store the original preset name to restore after tracker generation let originalPresetName = null; @@ -31,7 +34,7 @@ let originalPresetName = null; * Gets the current preset name using the /preset command * @returns {Promise} Current preset name or null if unavailable */ -async function getCurrentPresetName() { +export async function getCurrentPresetName() { try { // Use /preset without arguments to get the current preset name const result = await executeSlashCommandsOnChatInput('/preset', { quiet: true }); @@ -55,12 +58,14 @@ async function getCurrentPresetName() { console.error('[RPG Companion] Error getting current preset:', error); return null; } -}/** +} + +/** * Switches to a specific preset by name using the /preset slash command * @param {string} presetName - Name of the preset to switch to * @returns {Promise} True if switching succeeded, false otherwise */ -async function switchToPreset(presetName) { +export async function switchToPreset(presetName) { try { // Use the /preset slash command to switch presets // 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'); } - // Render the updated data (outside the message check, always render) + // Render the updated data renderUserStats(); renderInfoBox(); renderThoughts(); @@ -216,6 +221,26 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough // Save to chat metadata 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) { @@ -241,3 +266,25 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough setLastActionWasSwipe(false); } } + +/** + * Parses character names from Present Characters thoughts data + * @param {string} characterThoughtsData - Raw character thoughts data + * @returns {Array} 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; +} diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index d62600d..28cc9c8 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -626,3 +626,85 @@ export async function generateSeparateUpdatePrompt() { 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} characterNames - Array of character names to generate prompts for + * @returns {Promise>} 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`; + 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 = `\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; +} diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js index 76f92f3..f52bf33 100644 --- a/src/systems/rendering/thoughts.js +++ b/src/systems/rendering/thoughts.js @@ -17,6 +17,7 @@ import { import { saveChatData } from '../../core/persistence.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { saveSettings } from '../../core/persistence.js'; +import { isGenerating, regenerateAvatar } from '../features/avatarGenerator.js'; /** * Helper to log to both console and debug logs array @@ -110,12 +111,21 @@ function namesMatch(cardName, aiName) { function getCharacterAvatar(characterName) { // First, check if there's a custom NPC avatar if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) { - debugLog(`[RPG Thoughts] Found custom NPC avatar for: ${characterName}`); - return extensionSettings.npcAvatars[characterName]; + const avatar = 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 let characterPortrait = FALLBACK_AVATAR_DATA_URI; + let hasAvatar = false; // For group chats, search through group members first if (selected_group) { @@ -129,6 +139,7 @@ function getCharacterAvatar(characterName) { if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); if (thumbnailUrl) { + hasAvatar = true; return thumbnailUrl; } } @@ -147,6 +158,7 @@ function getCharacterAvatar(characterName) { if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); if (thumbnailUrl) { + hasAvatar = true; return thumbnailUrl; } } @@ -157,6 +169,7 @@ function getCharacterAvatar(characterName) { characters[this_chid].name && namesMatch(characters[this_chid].name, characterName)) { const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); if (thumbnailUrl) { + hasAvatar = true; return thumbnailUrl; } } @@ -463,7 +476,7 @@ export function renderThoughts() { html += '
'; html += `
-
+
${escapedDefaultName}
⚖️
@@ -500,7 +513,7 @@ export function renderThoughts() { // Find character portrait using the new helper function 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 let relationshipBadge = '⚖️'; // Default @@ -519,10 +532,14 @@ export function renderThoughts() { // Escape character name for use in HTML attributes const escapedName = escapeHtmlAttr(char.name); + // Check if avatar is being generated + const isCurrentlyGenerating = isGenerating(char.name); + html += `
-
+
${escapedName} + ${isCurrentlyGenerating ? '
' : ''} ${hasRelationshipEnabled ? `
${relationshipBadge}
` : ''}
@@ -606,8 +623,8 @@ export function renderThoughts() { uploadNpcAvatar(characterName); }); - // Add event handler for removing custom avatars (right-click) - $thoughtsContainer.find('.rpg-avatar-upload').on('contextmenu', function(e) { + // Add event handler for regenerating avatars (right-click) + $thoughtsContainer.find('.rpg-avatar-upload').on('contextmenu', async function(e) { // Prevent triggering if clicking on the relationship badge if ($(e.target).hasClass('rpg-relationship-badge') || $(e.target).closest('.rpg-relationship-badge').length > 0) { return; @@ -615,17 +632,42 @@ export function renderThoughts() { e.preventDefault(); // Prevent default context menu const characterName = $(this).data('character'); + const $avatarEl = $(this); - // Check if this character has a custom avatar - if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) { - // Remove the custom avatar - delete extensionSettings.npcAvatars[characterName]; - saveSettings(); - console.log(`[RPG Companion] Removed custom avatar for: ${characterName}`); - - // Re-render to show the default avatar - renderThoughts(); + // Check if auto-generation is enabled + if (!extensionSettings.autoGenerateAvatars) { + // If auto-generation is disabled, just remove the custom avatar + if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) { + delete extensionSettings.npcAvatars[characterName]; + saveSettings(); + console.log(`[RPG Companion] Removed custom avatar for: ${characterName}`); + renderThoughts(); + } + return; } + + // Show generating state with spinner overlay + $avatarEl.addClass('rpg-avatar-generating'); + if (!$avatarEl.find('.rpg-generating-overlay').length) { + $avatarEl.append('
'); + } + 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 diff --git a/style.css b/style.css index a534144..981a00a 100644 --- a/style.css +++ b/style.css @@ -6767,3 +6767,37 @@ body:has(.rpg-panel.rpg-position-left) #sheld { background: linear-gradient(180deg, #4a9eff 0%, transparent 100%); 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; +} diff --git a/template.html b/template.html index bf2b7a3..4da836a 100644 --- a/template.html +++ b/template.html @@ -245,6 +245,27 @@ Shows parser logs in a mobile-friendly UI panel. Useful for troubleshooting. Look for the red bug button. + + + + Automatically generate avatars for characters without custom images using the Image Generation Plugin + + + +