diff --git a/index.js b/index.js
index 6345a62..3d7a0e2 100644
--- a/index.js
+++ b/index.js
@@ -48,48 +48,22 @@ import { parseResponse, parseUserStats } from './src/systems/generation/parser.j
import { updateRPGData } from './src/systems/generation/apiClient.js';
import { onGenerationStarted } from './src/systems/generation/injector.js';
+// Rendering modules
+import { getSafeThumbnailUrl } from './src/utils/avatars.js';
+import { renderUserStats } from './src/systems/rendering/userStats.js';
+import { renderInfoBox, updateInfoBoxField } from './src/systems/rendering/infoBox.js';
+import {
+ renderThoughts,
+ updateCharacterField,
+ updateChatThoughts,
+ createThoughtPanel
+} from './src/systems/rendering/thoughts.js';
+
// Old state variable declarations removed - now imported from core modules
// (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js)
-/**
- * Safely attempts to get a thumbnail URL with proper error handling.
- * Returns null if the URL cannot be generated to avoid 400 Bad Request errors.
- *
- * @param {string} type - The type of thumbnail ('persona' or 'avatar')
- * @param {string} filename - The filename to get thumbnail for
- * @returns {string|null} - The thumbnail URL or null if it fails
- */
-function getSafeThumbnailUrl(type, filename) {
- // Return null if no filename provided
- if (!filename || filename === 'none') {
- console.log(`[RPG Companion] No valid filename provided for ${type} thumbnail`);
- return null;
- }
-
- try {
- // Attempt to get thumbnail URL from SillyTavern API
- const url = getThumbnailUrl(type, filename);
-
- // Validate that we got a string back
- if (typeof url !== 'string' || url.trim() === '') {
- console.warn(`[RPG Companion] getThumbnailUrl returned invalid result for ${type}:`, filename);
- return null;
- }
-
- console.log(`[RPG Companion] Successfully generated ${type} thumbnail URL for: ${filename}`);
- return url;
- } catch (error) {
- // Log detailed error information for debugging
- console.error(`[RPG Companion] Failed to get ${type} thumbnail for "${filename}":`, error);
- console.error('[RPG Companion] Error details:', {
- type,
- filename,
- errorMessage: error.message,
- errorStack: error.stack
- });
- return null;
- }
-}
+// Utility functions removed - now imported from src/utils/avatars.js
+// (getSafeThumbnailUrl)
// Persistence functions removed - now imported from src/core/persistence.js
// (loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData)
@@ -2070,1383 +2044,10 @@ function updateGenerationModeUI() {
}
}
+// Rendering functions removed - now imported from src/systems/rendering/*
+// (renderUserStats, renderInfoBox, renderThoughts, updateInfoBoxField,
+// updateCharacterField, updateChatThoughts, createThoughtPanel)
-/**
- * Renders the user stats with fancy progress bars.
- */
-/**
- * Renders the user stats with fancy progress bars.
- */
-function renderUserStats() {
- if (!extensionSettings.showUserStats || !$userStatsContainer) {
- return;
- }
-
- const stats = extensionSettings.userStats;
- const userName = getContext().name1;
-
- // Initialize lastGeneratedData.userStats if it doesn't exist
- if (!lastGeneratedData.userStats) {
- lastGeneratedData.userStats = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\nInventory: ${stats.inventory}`;
- }
-
- // Get user portrait - handle both default-user and custom persona folders
- // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
- let userPortrait = FALLBACK_AVATAR_DATA_URI;
-
- if (user_avatar) {
- // Try to get the thumbnail using our safe helper
- const thumbnailUrl = getSafeThumbnailUrl('persona', user_avatar);
- if (thumbnailUrl) {
- userPortrait = thumbnailUrl;
- }
- }
-
- // Create gradient from low to high color
- const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`;
-
- const html = `
-
-
-
-

-
-
- ${stats.inventory || 'None'}
-
-
-
-
-
-
Health:
-
-
${stats.health}%
-
-
-
-
Satiety:
-
-
${stats.satiety}%
-
-
-
-
Energy:
-
-
${stats.energy}%
-
-
-
-
Hygiene:
-
-
${stats.hygiene}%
-
-
-
-
Arousal:
-
-
${stats.arousal}%
-
-
-
-
-
${stats.mood}
-
${stats.conditions}
-
-
-
-
-
-
-
-
STR
-
-
- ${extensionSettings.classicStats.str}
-
-
-
-
-
DEX
-
-
- ${extensionSettings.classicStats.dex}
-
-
-
-
-
CON
-
-
- ${extensionSettings.classicStats.con}
-
-
-
-
-
INT
-
-
- ${extensionSettings.classicStats.int}
-
-
-
-
-
WIS
-
-
- ${extensionSettings.classicStats.wis}
-
-
-
-
-
CHA
-
-
- ${extensionSettings.classicStats.cha}
-
-
-
-
-
-
-
- `;
-
- $userStatsContainer.html(html);
-
- // Add event listeners for editable stat values
- $('.rpg-editable-stat').on('blur', function() {
- const field = $(this).data('field');
- const textValue = $(this).text().replace('%', '').trim();
- let value = parseInt(textValue);
-
- // Validate and clamp value between 0 and 100
- if (isNaN(value)) {
- value = 0;
- }
- value = Math.max(0, Math.min(100, value));
-
- // Update the setting
- extensionSettings.userStats[field] = value;
-
- // Also update lastGeneratedData to keep it in sync
- if (!lastGeneratedData.userStats) {
- lastGeneratedData.userStats = '';
- }
- // Regenerate the userStats text with updated value
- const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
- lastGeneratedData.userStats = statsText;
-
- saveSettings();
- saveChatData();
- updateMessageSwipeData();
-
- // Re-render to update the bar
- renderUserStats();
- });
-
- // Add event listener for inventory editing
- $('.rpg-inventory-items.rpg-editable').on('blur', function() {
- const value = $(this).text().trim();
- extensionSettings.userStats.inventory = value || 'None';
-
- // Update lastGeneratedData
- const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
- lastGeneratedData.userStats = statsText;
-
- saveSettings();
- saveChatData();
- updateMessageSwipeData();
- });
-
- // Add event listeners for mood/conditions editing
- $('.rpg-mood-emoji.rpg-editable').on('blur', function() {
- const value = $(this).text().trim();
- extensionSettings.userStats.mood = value || '😐';
-
- // Update lastGeneratedData
- const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
- lastGeneratedData.userStats = statsText;
-
- saveSettings();
- saveChatData();
- updateMessageSwipeData();
- });
-
- $('.rpg-mood-conditions.rpg-editable').on('blur', function() {
- const value = $(this).text().trim();
- extensionSettings.userStats.conditions = value || 'None';
-
- // Update lastGeneratedData
- const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
- lastGeneratedData.userStats = statsText;
-
- saveSettings();
- saveChatData();
- updateMessageSwipeData();
- });
-}
-
-/**
- * Renders the info box as a visual dashboard.
- */
-function renderInfoBox() {
- if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
- return;
- }
-
- // Add updating class for animation
- if (extensionSettings.enableAnimations) {
- $infoBoxContainer.addClass('rpg-content-updating');
- }
-
- // If no data yet, show placeholder
- if (!lastGeneratedData.infoBox) {
- const placeholderHtml = `
-
- `;
- $infoBoxContainer.html(placeholderHtml);
- if (extensionSettings.enableAnimations) {
- setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
- }
- return;
- }
-
- // console.log('[RPG Companion] renderInfoBox called with data:', lastGeneratedData.infoBox);
-
- // Parse the info box data
- const lines = lastGeneratedData.infoBox.split('\n');
- // console.log('[RPG Companion] Info Box split into lines:', lines);
- const data = {
- date: '',
- weekday: '',
- month: '',
- year: '',
- weatherEmoji: '',
- weatherForecast: '',
- temperature: '',
- tempValue: 0,
- timeStart: '',
- timeEnd: '',
- location: '',
- characters: []
- };
-
- for (const line of lines) {
- // console.log('[RPG Companion] Processing line:', line);
-
- if (line.includes('🗓️:')) {
- // console.log('[RPG Companion] → Matched DATE');
- const dateStr = line.replace('🗓️:', '').trim();
- // Parse format: "Weekday, Month Day, Year" or "Weekday, Month, Year"
- const dateParts = dateStr.split(',').map(p => p.trim());
- data.weekday = dateParts[0] || '';
- data.month = dateParts[1] || '';
- data.year = dateParts[2] || '';
- data.date = dateStr;
- } else if (line.includes('🌡️:')) {
- // console.log('[RPG Companion] → Matched TEMPERATURE');
- const tempStr = line.replace('🌡️:', '').trim();
- data.temperature = tempStr;
- // Extract numeric value
- const tempMatch = tempStr.match(/(-?\d+)/);
- if (tempMatch) {
- data.tempValue = parseInt(tempMatch[1]);
- }
- } else if (line.includes('🕒:')) {
- // console.log('[RPG Companion] → Matched TIME');
- const timeStr = line.replace('🕒:', '').trim();
- data.time = timeStr;
- // Parse "HH:MM → HH:MM" format
- const timeParts = timeStr.split('→').map(t => t.trim());
- data.timeStart = timeParts[0] || '';
- data.timeEnd = timeParts[1] || '';
- } else if (line.includes('🗺️:')) {
- // console.log('[RPG Companion] → Matched LOCATION');
- data.location = line.replace('🗺️:', '').trim();
- } else {
- // Check if it's a weather line
- // Since \p{Emoji} doesn't work reliably, use a simpler approach
- const hasColon = line.includes(':');
- const notInfoBox = !line.includes('Info Box');
- const notDivider = !line.includes('---');
- const notCodeFence = !line.trim().startsWith('```');
-
- // console.log('[RPG Companion] → Checking weather conditions:', {
- // line: line,
- // hasColon: hasColon,
- // notInfoBox: notInfoBox,
- // notDivider: notDivider
- // });
-
- if (hasColon && notInfoBox && notDivider && notCodeFence && line.trim().length > 0) {
- // Match format: [Weather Emoji]: [Forecast]
- // Capture everything before colon as emoji, everything after as forecast
- // console.log('[RPG Companion] → Testing WEATHER match for:', line);
- const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/);
- if (weatherMatch) {
- const potentialEmoji = weatherMatch[1].trim();
- const forecast = weatherMatch[2].trim();
-
- // If the first part is short (likely emoji), treat as weather
- if (potentialEmoji.length <= 5) {
- data.weatherEmoji = potentialEmoji;
- data.weatherForecast = forecast;
- // console.log('[RPG Companion] ✓ Weather parsed:', data.weatherEmoji, data.weatherForecast);
- } else {
- // console.log('[RPG Companion] ✗ First part too long for emoji:', potentialEmoji);
- }
- } else {
- // console.log('[RPG Companion] ✗ Weather regex did not match');
- }
- } else {
- // console.log('[RPG Companion] → No match for this line');
- }
- }
- }
-
- // console.log('[RPG Companion] Parsed Info Box data:', {
- // date: data.date,
- // weatherEmoji: data.weatherEmoji,
- // weatherForecast: data.weatherForecast,
- // temperature: data.temperature,
- // timeStart: data.timeStart,
- // location: data.location
- // });
-
- // Build visual dashboard HTML
- // Row 1: Date, Weather, Temperature, Time widgets
- let html = '';
-
- // Calendar widget - always show (editable even if empty)
- const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON';
- const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY';
- const yearDisplay = data.year || 'YEAR';
- html += `
-
- `;
-
- // Weather widget - always show (editable even if empty)
- const weatherEmoji = data.weatherEmoji || '🌤️';
- const weatherForecast = data.weatherForecast || 'Weather';
- html += `
-
- `;
-
- // Temperature widget - always show (editable even if empty)
- const tempDisplay = data.temperature || '20°C';
- const tempValue = data.tempValue || 20;
- const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100));
- const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560';
- html += `
-
- `;
-
- // Time widget - always show (editable even if empty)
- const timeDisplay = data.timeStart || '12:00';
- // Parse time for clock hands
- const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
- let hourAngle = 0;
- let minuteAngle = 0;
- if (timeMatch) {
- const hours = parseInt(timeMatch[1]);
- const minutes = parseInt(timeMatch[2]);
- hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
- minuteAngle = minutes * 6; // 6° per minute
- }
- html += `
-
- `;
-
- html += '
';
-
- // Row 2: Location widget (full width) - always show (editable even if empty)
- const locationDisplay = data.location || 'Location';
- html += `
-
- `;
-
- $infoBoxContainer.html(html);
-
- // Add event handlers for editable Info Box fields
- $infoBoxContainer.find('.rpg-editable').on('blur', function() {
- const field = $(this).data('field');
- const value = $(this).text().trim();
- updateInfoBoxField(field, value);
- });
-
- // Remove updating class after animation
- if (extensionSettings.enableAnimations) {
- setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
- }
-}
-
-/**
- * Renders character thoughts (Present Characters).
- */
-function renderThoughts() {
- if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
- return;
- }
-
- // Add updating class for animation
- if (extensionSettings.enableAnimations) {
- $thoughtsContainer.addClass('rpg-content-updating');
- }
-
- // Initialize if no data yet
- if (!lastGeneratedData.characterThoughts) {
- lastGeneratedData.characterThoughts = '';
- }
-
- const lines = lastGeneratedData.characterThoughts.split('\n');
- const presentCharacters = [];
-
- // console.log('[RPG Companion] Raw Present Characters:', lastGeneratedData.characterThoughts);
- // console.log('[RPG Companion] Split into lines:', lines);
-
- // Parse format: [Emoji]: [Name, Status, Demeanor] | [Relationship] | [Thoughts]
- for (const line of lines) {
- // Skip empty lines, headers, dividers, and code fences
- if (line.trim() &&
- !line.includes('Present Characters') &&
- !line.includes('---') &&
- !line.trim().startsWith('```')) {
-
- // Match the new format with pipes
- const parts = line.split('|').map(p => p.trim());
-
- if (parts.length >= 2) {
- // First part: [Emoji]: [Name, Status, Demeanor]
- const firstPart = parts[0].trim();
- const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
-
- if (emojiMatch) {
- const emoji = emojiMatch[1].trim();
- const info = emojiMatch[2].trim();
- const relationship = parts[1].trim(); // Enemy/Neutral/Friend/Lover
- const thoughts = parts[2] ? parts[2].trim() : '';
-
- // Parse name from info (first part before comma)
- const infoParts = info.split(',').map(p => p.trim());
- const name = infoParts[0] || '';
- const traits = infoParts.slice(1).join(', ');
-
- if (name && name.toLowerCase() !== 'unavailable') {
- presentCharacters.push({ emoji, name, traits, relationship, thoughts });
- // console.log('[RPG Companion] Parsed character:', { name, relationship });
- }
- }
- }
- }
- }
-
- // Relationship status to emoji mapping
- const relationshipEmojis = {
- 'Enemy': '⚔️',
- 'Neutral': '⚖️',
- 'Friend': '⭐',
- 'Lover': '❤️'
- };
-
- // Build HTML
- let html = '';
-
- // console.log('[RPG Companion] Total characters parsed:', presentCharacters.length);
- // console.log('[RPG Companion] Characters array:', presentCharacters);
-
- // If no characters parsed, show a placeholder editable card
- if (presentCharacters.length === 0) {
- // Get default character portrait (try to use the current character if in 1-on-1 chat)
- // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
- let defaultPortrait = FALLBACK_AVATAR_DATA_URI;
- let defaultName = 'Character';
-
- if (this_chid !== undefined && characters[this_chid]) {
- if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') {
- const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
- if (thumbnailUrl) {
- defaultPortrait = thumbnailUrl;
- }
- }
- defaultName = characters[this_chid].name || 'Character';
- }
-
- html += '';
- html += `
-
-
-

-
⚖️
-
-
-
- `;
- html += '
';
- } else {
- html += '';
- for (const char of presentCharacters) {
- // Find character portrait
- // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
- let characterPortrait = FALLBACK_AVATAR_DATA_URI;
-
- // console.log('[RPG Companion] Looking for avatar for:', char.name);
-
- // For group chats, search through group members first
- if (selected_group) {
- const groupMembers = getGroupMembers(selected_group);
- const matchingMember = groupMembers.find(member =>
- member && member.name && member.name.toLowerCase() === char.name.toLowerCase()
- );
-
- if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
- const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
- if (thumbnailUrl) {
- characterPortrait = thumbnailUrl;
- }
- }
- }
-
- // For regular chats or if not found in group, search all characters
- if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) {
- const matchingCharacter = characters.find(c =>
- c && c.name && c.name.toLowerCase() === char.name.toLowerCase()
- );
-
- if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
- const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
- if (thumbnailUrl) {
- characterPortrait = thumbnailUrl;
- }
- }
- }
-
- // If this is the current character in a 1-on-1 chat, use their portrait
- if (this_chid !== undefined && characters[this_chid] &&
- characters[this_chid].name && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) {
- const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
- if (thumbnailUrl) {
- characterPortrait = thumbnailUrl;
- }
- }
-
- // Get relationship emoji
- const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️';
-
- html += `
-
-
-

-
${relationshipEmoji}
-
-
-
- `;
- }
- html += '
';
- }
-
- $thoughtsContainer.html(html);
-
- // Add event handlers for editable character fields
- $thoughtsContainer.find('.rpg-editable').on('blur', function() {
- const character = $(this).data('character');
- const field = $(this).data('field');
- const value = $(this).text().trim();
- updateCharacterField(character, field, value);
- });
-
- // Remove updating class after animation
- if (extensionSettings.enableAnimations) {
- setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600);
- }
-
- // Update chat overlay if enabled
- if (extensionSettings.showThoughtsInChat) {
- updateChatThoughts();
- }
-}
-
-/**
- * Updates a specific field in the Info Box data and re-renders.
- */
-function updateInfoBoxField(field, value) {
- if (!lastGeneratedData.infoBox) {
- // Initialize with empty info box if it doesn't exist
- lastGeneratedData.infoBox = 'Info Box\n---\n';
- }
-
- // Reconstruct the Info Box text with updated field
- const lines = lastGeneratedData.infoBox.split('\n');
- let dateLineFound = false;
- let dateLineIndex = -1;
-
- // Find the date line
- for (let i = 0; i < lines.length; i++) {
- if (lines[i].includes('🗓️:')) {
- dateLineFound = true;
- dateLineIndex = i;
- break;
- }
- }
-
- const updatedLines = lines.map((line, index) => {
- if (field === 'month' && line.includes('🗓️:')) {
- const parts = line.split(',');
- if (parts.length >= 2) {
- // parts[0] = "🗓️: Weekday", parts[1] = " Month", parts[2] = " Year"
- parts[1] = ' ' + value;
- return parts.join(',');
- } else if (parts.length === 1) {
- // No existing month/year, add them
- return `${parts[0]}, ${value}, YEAR`;
- }
- } else if (field === 'weekday' && line.includes('🗓️:')) {
- const parts = line.split(',');
- // Keep the emoji, just update the weekday
- const month = parts[1] ? parts[1].trim() : 'Month';
- const year = parts[2] ? parts[2].trim() : 'YEAR';
- return `🗓️: ${value}, ${month}, ${year}`;
- } else if (field === 'year' && line.includes('🗓️:')) {
- const parts = line.split(',');
- if (parts.length >= 3) {
- parts[2] = ' ' + value;
- return parts.join(',');
- } else if (parts.length === 2) {
- // No existing year, add it
- return `${parts[0]}, ${parts[1]}, ${value}`;
- } else if (parts.length === 1) {
- // No existing month/year, add them
- return `${parts[0]}, Month, ${value}`;
- }
- } else if (field === 'weatherEmoji' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) {
- // This is the weather line
- const parts = line.split(':');
- if (parts.length >= 2) {
- return `${value}: ${parts.slice(1).join(':').trim()}`;
- }
- } else if (field === 'weatherForecast' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) {
- // This is the weather line
- const parts = line.split(':');
- if (parts.length >= 2) {
- return `${parts[0].trim()}: ${value}`;
- }
- } else if (field === 'temperature' && line.includes('🌡️:')) {
- return `🌡️: ${value}`;
- } else if (field === 'timeStart' && line.includes('🕒:')) {
- // Update time format: "HH:MM → HH:MM"
- // When user edits, set both start and end time to the new value
- return `🕒: ${value} → ${value}`;
- } else if (field === 'location' && line.includes('🗺️:')) {
- return `🗺️: ${value}`;
- }
- return line;
- });
-
- // If editing a date field but no date line exists, create one after the divider
- if ((field === 'month' || field === 'weekday' || field === 'year') && !dateLineFound) {
- // Find the divider line
- const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
- if (dividerIndex >= 0) {
- // Create initial date line with the edited field
- let newDateLine = '';
- if (field === 'weekday') {
- newDateLine = `🗓️: ${value}, Month, YEAR`;
- } else if (field === 'month') {
- newDateLine = `🗓️: Weekday, ${value}, YEAR`;
- } else if (field === 'year') {
- newDateLine = `🗓️: Weekday, Month, ${value}`;
- }
- // Insert after the divider
- updatedLines.splice(dividerIndex + 1, 0, newDateLine);
- }
- }
-
- // If editing weather but no weather line exists, create one
- if ((field === 'weatherEmoji' || field === 'weatherForecast')) {
- let weatherLineFound = false;
- for (const line of updatedLines) {
- // Check if this is a weather line (has emoji and forecast, not one of the special fields)
- if (line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) {
- weatherLineFound = true;
- break;
- }
- }
-
- if (!weatherLineFound) {
- const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
- if (dividerIndex >= 0) {
- let newWeatherLine = '';
- if (field === 'weatherEmoji') {
- newWeatherLine = `${value}: Weather`;
- } else if (field === 'weatherForecast') {
- newWeatherLine = `🌤️: ${value}`;
- }
- // Insert after date line if it exists, otherwise after divider
- const dateIndex = updatedLines.findIndex(line => line.includes('🗓️:'));
- const insertIndex = dateIndex >= 0 ? dateIndex + 1 : dividerIndex + 1;
- updatedLines.splice(insertIndex, 0, newWeatherLine);
- }
- }
- }
-
- // If editing temperature but no temperature line exists, create one
- if (field === 'temperature') {
- const tempLineFound = updatedLines.some(line => line.includes('🌡️:'));
- if (!tempLineFound) {
- const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
- if (dividerIndex >= 0) {
- const newTempLine = `🌡️: ${value}`;
- // Find last non-empty line before creating position
- let insertIndex = dividerIndex + 1;
- for (let i = 0; i < updatedLines.length; i++) {
- if (updatedLines[i].includes('🗓️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) {
- insertIndex = i + 1;
- }
- }
- updatedLines.splice(insertIndex, 0, newTempLine);
- }
- }
- }
-
- // If editing time but no time line exists, create one
- if (field === 'timeStart') {
- const timeLineFound = updatedLines.some(line => line.includes('🕒:'));
- if (!timeLineFound) {
- const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
- if (dividerIndex >= 0) {
- const newTimeLine = `🕒: ${value} → ${value}`;
- // Find last non-empty line before creating position
- let insertIndex = dividerIndex + 1;
- for (let i = 0; i < updatedLines.length; i++) {
- if (updatedLines[i].includes('🗓️:') || updatedLines[i].includes('🌡️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) {
- insertIndex = i + 1;
- }
- }
- updatedLines.splice(insertIndex, 0, newTimeLine);
- }
- }
- }
-
- // If editing location but no location line exists, create one
- if (field === 'location') {
- const locationLineFound = updatedLines.some(line => line.includes('🗺️:'));
- if (!locationLineFound) {
- const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
- if (dividerIndex >= 0) {
- const newLocationLine = `🗺️: ${value}`;
- // Insert at the end (before any empty lines)
- let insertIndex = updatedLines.length;
- for (let i = updatedLines.length - 1; i >= 0; i--) {
- if (updatedLines[i].trim() !== '') {
- insertIndex = i + 1;
- break;
- }
- }
- updatedLines.splice(insertIndex, 0, newLocationLine);
- }
- }
- }
-
- lastGeneratedData.infoBox = updatedLines.join('\n');
-
- // Update the message's swipe data
- const chat = getContext().chat;
- if (chat && chat.length > 0) {
- for (let i = chat.length - 1; i >= 0; i--) {
- const message = chat[i];
- if (!message.is_user) {
- if (message.extra && message.extra.rpg_companion_swipes) {
- const swipeId = message.swipe_id || 0;
- if (message.extra.rpg_companion_swipes[swipeId]) {
- message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
- // console.log('[RPG Companion] Updated infoBox in message swipe data');
- }
- }
- break;
- }
- }
- }
-
- saveChatData();
- renderInfoBox();
-}
-
-/**
- * Updates a specific character field in Present Characters data and re-renders.
- */
-function updateCharacterField(characterName, field, value) {
- // console.log('[RPG Companion] 📝 updateCharacterField called - character:', characterName, 'field:', field, 'value:', value);
- // console.log('[RPG Companion] 📝 Current lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
-
- // Initialize if it doesn't exist
- if (!lastGeneratedData.characterThoughts) {
- lastGeneratedData.characterThoughts = 'Present Characters\n---\n';
- }
-
- const lines = lastGeneratedData.characterThoughts.split('\n');
- let characterFound = false;
-
- const updatedLines = lines.map(line => {
- // Case-insensitive character name matching
- if (line.toLowerCase().includes(characterName.toLowerCase())) {
- characterFound = true;
- const parts = line.split('|').map(p => p.trim());
- if (parts.length >= 2) {
- const firstPart = parts[0];
- const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
-
- if (emojiMatch) {
- let emoji = emojiMatch[1].trim();
- let info = emojiMatch[2].trim();
- let relationship = parts[1];
- let thoughts = parts[2] || '';
-
- const infoParts = info.split(',').map(p => p.trim());
- let name = infoParts[0];
- let traits = infoParts.slice(1).join(', ');
-
- if (field === 'emoji') {
- emoji = value;
- } else if (field === 'name') {
- name = value;
- } else if (field === 'traits') {
- traits = value;
- } else if (field === 'thoughts') {
- thoughts = value;
- } else if (field === 'relationship') {
- const emojiToRelationship = {
- '⚔️': 'Enemy',
- '⚖️': 'Neutral',
- '⭐': 'Friend',
- '❤️': 'Lover'
- };
- relationship = emojiToRelationship[value] || value;
- }
-
- const newInfo = traits ? `${name}, ${traits}` : name;
- return `${emoji}: ${newInfo} | ${relationship} | ${thoughts}`;
- }
- }
- }
- return line;
- });
-
- // If character wasn't found, create a new character line
- if (!characterFound) {
- // Find the divider line
- const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
- if (dividerIndex >= 0) {
- // Create initial character line with the edited field
- let emoji = '😊';
- let name = characterName;
- let traits = 'Traits';
- let relationship = 'Neutral';
- let thoughts = '';
-
- // Apply the edited field
- if (field === 'emoji') {
- emoji = value;
- } else if (field === 'name') {
- name = value;
- } else if (field === 'traits') {
- traits = value;
- } else if (field === 'thoughts') {
- thoughts = value;
- } else if (field === 'relationship') {
- const emojiToRelationship = {
- '⚔️': 'Enemy',
- '⚖️': 'Neutral',
- '⭐': 'Friend',
- '❤️': 'Lover'
- };
- relationship = emojiToRelationship[value] || value;
- }
-
- const newCharacterLine = `${emoji}: ${name}, ${traits} | ${relationship} | ${thoughts}`;
- // Insert after the divider
- updatedLines.splice(dividerIndex + 1, 0, newCharacterLine);
- }
- }
-
- lastGeneratedData.characterThoughts = updatedLines.join('\n');
- // console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
-
- // Also update the last assistant message's swipe data
- const chat = getContext().chat;
- if (chat && chat.length > 0) {
- // Find the last assistant message
- for (let i = chat.length - 1; i >= 0; i--) {
- const message = chat[i];
- if (!message.is_user) {
- // Found last assistant message - update its swipe data
- if (message.extra && message.extra.rpg_companion_swipes) {
- const swipeId = message.swipe_id || 0;
- if (message.extra.rpg_companion_swipes[swipeId]) {
- message.extra.rpg_companion_swipes[swipeId].characterThoughts = updatedLines.join('\n');
- // console.log('[RPG Companion] Updated thoughts in message swipe data');
- }
- }
- break;
- }
- }
- }
-
- saveChatData();
-
- // Always update the sidebar panel
- renderThoughts();
-
- // For thoughts edited from the bubble, delay recreation to allow blur event to complete
- // This ensures the edit is saved first, then the bubble is recreated with correct layout
- if (field === 'thoughts') {
- setTimeout(() => {
- updateChatThoughts();
- }, 100);
- } else {
- // For other fields, recreate immediately
- updateChatThoughts();
- }
-}
-
-/**
- * Updates or removes thought overlays in the chat.
- */
-function updateChatThoughts() {
- // console.log('[RPG Companion] ======== updateChatThoughts called ========');
- // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled);
- // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat);
- // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked'));
- // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
-
- // Remove existing thought panel and icon
- $('#rpg-thought-panel').remove();
- $('#rpg-thought-icon').remove();
- $('#chat').off('scroll.thoughtPanel');
- $(window).off('resize.thoughtPanel');
- $(document).off('click.thoughtPanel');
-
- // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return
- if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) {
- // console.log('[RPG Companion] Thoughts in chat disabled or no data');
- return;
- }
-
- // Parse the Present Characters data to get thoughts
- const lines = lastGeneratedData.characterThoughts.split('\n');
- const thoughtsArray = []; // Array of {name, emoji, thought}
-
- // console.log('[RPG Companion] Parsing thoughts from lines:', lines);
-
- for (const line of lines) {
- if (line.trim() &&
- !line.includes('Present Characters') &&
- !line.includes('---') &&
- !line.trim().startsWith('```')) {
-
- const parts = line.split('|').map(p => p.trim());
- // console.log('[RPG Companion] Line parts:', parts);
-
- if (parts.length >= 3) {
- const firstPart = parts[0].trim();
- const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
-
- if (emojiMatch) {
- const emoji = emojiMatch[1].trim();
- const info = emojiMatch[2].trim();
- const thoughts = parts[2] ? parts[2].trim() : '';
-
- const infoParts = info.split(',').map(p => p.trim());
- const name = infoParts[0] || '';
-
- // console.log('[RPG Companion] Parsed thought - Name:', name, 'Thought:', thoughts);
-
- if (name && thoughts && name.toLowerCase() !== 'unavailable') {
- thoughtsArray.push({ name: name.toLowerCase(), emoji, thought: thoughts });
- // console.log('[RPG Companion] Added to thoughtsArray:', name.toLowerCase());
- }
- }
- }
- }
- }
-
- // If no thoughts parsed, return
- if (thoughtsArray.length === 0) {
- // console.log('[RPG Companion] No thoughts parsed, returning');
- return;
- }
-
- // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length);
- // console.log('[RPG Companion] Thoughts array:', thoughtsArray);
-
- // Find the last message to position near
- const $messages = $('#chat .mes');
- let $targetMessage = null;
-
- // Find the most recent non-user message
- for (let i = $messages.length - 1; i >= 0; i--) {
- const $message = $messages.eq(i);
- if ($message.attr('is_user') !== 'true') {
- $targetMessage = $message;
- break;
- }
- }
-
- if (!$targetMessage) {
- // console.log('[RPG Companion] No target message found');
- return;
- }
-
- // Create the thought panel with all thoughts
- createThoughtPanel($targetMessage, thoughtsArray);
-}
-
-/**
- * Creates or updates the floating thought panel positioned next to the character's avatar
- */
-function createThoughtPanel($message, thoughtsArray) {
- // Remove existing thought panel
- $('#rpg-thought-panel').remove();
- $('#rpg-thought-icon').remove();
-
- // Get the avatar position from the message
- const $avatar = $message.find('.avatar img');
- if (!$avatar.length) {
- // console.log('[RPG Companion] No avatar found in message');
- return;
- }
-
- const avatarRect = $avatar[0].getBoundingClientRect();
- const panelPosition = extensionSettings.panelPosition;
- const theme = extensionSettings.theme;
-
- // Build thought bubbles HTML
- let thoughtsHtml = '';
- thoughtsArray.forEach((thought, index) => {
- thoughtsHtml += `
-
-
- ${thought.emoji}
-
-
- ${thought.thought}
-
-
- `;
- // Add divider between thoughts (except for last one)
- if (index < thoughtsArray.length - 1) {
- thoughtsHtml += '';
- }
- });
-
- // Create the floating thought panel with theme
- const $thoughtPanel = $(`
-
-
-
-
- ${thoughtsHtml}
-
-
- `);
-
- // Create the collapsed thought icon
- const $thoughtIcon = $(`
-
- 💭
-
- `);
-
- // Apply custom theme colors if custom theme
- if (theme === 'custom') {
- const customStyles = {
- '--rpg-bg': extensionSettings.customColors.bg,
- '--rpg-accent': extensionSettings.customColors.accent,
- '--rpg-text': extensionSettings.customColors.text,
- '--rpg-highlight': extensionSettings.customColors.highlight
- };
- $thoughtPanel.css(customStyles);
- $thoughtIcon.css(customStyles);
- }
-
- // Force a consistent width for the bubble to ensure proper positioning
- $thoughtPanel.css('width', '350px');
-
- // Append to body so it's not clipped by chat container
- $('body').append($thoughtPanel);
- $('body').append($thoughtIcon);
-
- // Position the panel next to the avatar
- const panelWidth = 350;
- const panelMargin = 20;
-
- let top = avatarRect.top + (avatarRect.height / 2);
- let left;
- let right;
- let useRightPosition = false;
- let iconTop = avatarRect.top;
- let iconLeft;
-
- // Detect mobile viewport (matches CSS breakpoint)
- const isMobile = window.innerWidth <= 1000;
-
- if (isMobile) {
- // On mobile: position icon horizontally centered on avatar
- // The CSS transform will shift it upward by 60px
- iconTop = avatarRect.top; // Start at avatar top (CSS will move it up)
- iconLeft = avatarRect.left + (avatarRect.width / 2) - 18; // Centered horizontally (18px = half of 36px icon width)
-
- // Center the thought panel horizontally on mobile
- left = window.innerWidth / 2 - panelWidth / 2;
- top = avatarRect.top + avatarRect.height + 60; // Position below icon with spacing
-
- // No side-specific classes on mobile
- $thoughtPanel.removeClass('rpg-thought-panel-left rpg-thought-panel-right');
- $thoughtIcon.removeClass('rpg-thought-icon-left rpg-thought-icon-right');
-
- console.log('[RPG Companion] Mobile thought icon positioning:', {
- isMobile,
- windowWidth: window.innerWidth,
- avatarLeft: avatarRect.left,
- avatarWidth: avatarRect.width,
- iconLeft,
- iconTop
- });
- } else if (panelPosition === 'left') {
- // Main panel is on left, so thought bubble goes to RIGHT side
- // Mirror the left side positioning: bubble should be same distance from avatar
- // but on the opposite side, extending to the right
- const chatContainer = $('#chat')[0];
- const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth };
-
- // Position bubble starting from chat edge, extending right
- left = chatRect.right + panelMargin; // Start at chat's right edge + margin
- useRightPosition = false; // Use left positioning so it extends right
- iconLeft = chatRect.right + 10; // Icon just at the chat edge
- $thoughtPanel.addClass('rpg-thought-panel-right');
- $thoughtIcon.addClass('rpg-thought-icon-right');
-
- // Position circles to flow from left (toward chat/avatar) to right (toward panel)
- $thoughtPanel.find('.rpg-thought-circles').css({
- top: 'calc(50% - 50px)',
- left: '-25px',
- bottom: 'auto',
- right: 'auto'
- });
- // Mirror the circle flow for right side (left-to-right)
- $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-start');
- $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '0' });
- $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '4px' });
- $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '8px' });
- } else {
- // Main panel is on right, so thought bubble goes on left (near avatar)
- left = avatarRect.left - panelWidth - panelMargin;
- iconLeft = avatarRect.left - 40;
- $thoughtPanel.addClass('rpg-thought-panel-left');
- $thoughtIcon.addClass('rpg-thought-icon-left');
-
- // Position circles to flow from avatar (left) to bubble (more left)
- // Circles should flow right-to-left when bubble is on left
- $thoughtPanel.find('.rpg-thought-circles').css({
- top: 'calc(50% - 50px)',
- right: '-25px',
- bottom: 'auto',
- left: 'auto'
- });
- // Keep the circle flow for left side (right-to-left) - default from CSS
- $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-end');
- $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '0' });
- $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '4px' });
- $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '8px' });
- }
-
- if (useRightPosition) {
- $thoughtPanel.css({
- top: `${top}px`,
- right: `${right}px`,
- left: 'auto' // Clear left positioning
- });
- } else {
- $thoughtPanel.css({
- top: `${top}px`,
- left: `${left}px`,
- right: 'auto' // Clear right positioning
- });
- }
-
- $thoughtIcon.css({
- top: `${iconTop}px`,
- left: `${iconLeft}px`,
- right: 'auto' // Clear any right positioning
- });
-
- // Initially hide the panel and show the icon
- $thoughtPanel.hide();
- $thoughtIcon.show();
-
- // console.log('[RPG Companion] Thought panel created at:', { top, left });
-
- // Close button functionality
- $thoughtPanel.find('.rpg-thought-close').on('click', function(e) {
- e.stopPropagation();
- $thoughtPanel.fadeOut(200);
- $thoughtIcon.fadeIn(200);
- });
-
- // Icon click to show panel
- $thoughtIcon.on('click', function(e) {
- e.stopPropagation();
- $thoughtIcon.fadeOut(200);
- $thoughtPanel.fadeIn(200);
- });
-
- // Add event handlers for editable thoughts in the bubble
- $thoughtPanel.find('.rpg-editable').on('blur', function() {
- const character = $(this).data('character');
- const field = $(this).data('field');
- const value = $(this).text().trim();
- // console.log('[RPG Companion] 💭 Thought bubble blur event - character:', character, 'field:', field, 'value:', value);
- updateCharacterField(character, field, value);
- });
-
- // RAF throttling for smooth position updates
- let positionUpdateRaf = null;
-
- // Update position on scroll with RAF throttling
- const updatePanelPosition = () => {
- if (!$message.is(':visible')) {
- $thoughtPanel.hide();
- $thoughtIcon.hide();
- return;
- }
-
- // Cancel any pending RAF
- if (positionUpdateRaf) {
- cancelAnimationFrame(positionUpdateRaf);
- }
-
- // Schedule update on next frame
- positionUpdateRaf = requestAnimationFrame(() => {
- const newAvatarRect = $avatar[0].getBoundingClientRect();
- const newTop = newAvatarRect.top + (newAvatarRect.height / 2);
- const newIconTop = newAvatarRect.top;
- let newLeft, newIconLeft;
-
- if (panelPosition === 'left') {
- // Position at chat's right edge, extending right
- const chatContainer = $('#chat')[0];
- const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth };
- newLeft = chatRect.right + panelMargin;
- newIconLeft = chatRect.right + 10;
-
- $thoughtPanel.css({
- top: `${newTop}px`,
- left: `${newLeft}px`,
- right: 'auto'
- });
- } else {
- // Left position relative to avatar
- newLeft = newAvatarRect.left - panelWidth - panelMargin;
- newIconLeft = newAvatarRect.left - 40;
-
- $thoughtPanel.css({
- top: `${newTop}px`,
- left: `${newLeft}px`,
- right: 'auto'
- });
- }
-
- $thoughtIcon.css({
- top: `${newIconTop}px`,
- left: `${newIconLeft}px`,
- right: 'auto'
- });
-
- if ($thoughtPanel.is(':visible')) {
- $thoughtPanel.show();
- }
- if ($thoughtIcon.is(':visible')) {
- $thoughtIcon.show();
- }
-
- positionUpdateRaf = null;
- });
- };
-
- // Update position on scroll and resize
- $('#chat').on('scroll.thoughtPanel', updatePanelPosition);
- $(window).on('resize.thoughtPanel', updatePanelPosition);
-
- // Remove panel when clicking outside (but not when clicking icon or panel)
- $(document).on('click.thoughtPanel', function(e) {
- if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) {
- // Hide the panel and show the icon instead of removing
- $thoughtPanel.fadeOut(200);
- $thoughtIcon.fadeIn(200);
- }
- });
-}
/**
* Commits the tracker data from the last assistant message to be used as source for next generation.
diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js
new file mode 100644
index 0000000..281f388
--- /dev/null
+++ b/src/systems/rendering/infoBox.js
@@ -0,0 +1,452 @@
+/**
+ * Info Box Rendering Module
+ * Handles rendering of the info box dashboard with weather, date, time, and location widgets
+ */
+
+import { getContext } from '../../../../../../extensions.js';
+import {
+ extensionSettings,
+ lastGeneratedData,
+ $infoBoxContainer
+} from '../../core/state.js';
+import { saveChatData } from '../../core/persistence.js';
+
+/**
+ * Renders the info box as a visual dashboard with calendar, weather, temperature, clock, and map widgets.
+ * Includes event listeners for editable fields.
+ */
+export function renderInfoBox() {
+ if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
+ return;
+ }
+
+ // Add updating class for animation
+ if (extensionSettings.enableAnimations) {
+ $infoBoxContainer.addClass('rpg-content-updating');
+ }
+
+ // If no data yet, show placeholder
+ if (!lastGeneratedData.infoBox) {
+ const placeholderHtml = `
+
+ `;
+ $infoBoxContainer.html(placeholderHtml);
+ if (extensionSettings.enableAnimations) {
+ setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
+ }
+ return;
+ }
+
+ // console.log('[RPG Companion] renderInfoBox called with data:', lastGeneratedData.infoBox);
+
+ // Parse the info box data
+ const lines = lastGeneratedData.infoBox.split('\n');
+ // console.log('[RPG Companion] Info Box split into lines:', lines);
+ const data = {
+ date: '',
+ weekday: '',
+ month: '',
+ year: '',
+ weatherEmoji: '',
+ weatherForecast: '',
+ temperature: '',
+ tempValue: 0,
+ timeStart: '',
+ timeEnd: '',
+ location: '',
+ characters: []
+ };
+
+ for (const line of lines) {
+ // console.log('[RPG Companion] Processing line:', line);
+
+ if (line.includes('🗓️:')) {
+ // console.log('[RPG Companion] → Matched DATE');
+ const dateStr = line.replace('🗓️:', '').trim();
+ // Parse format: "Weekday, Month Day, Year" or "Weekday, Month, Year"
+ const dateParts = dateStr.split(',').map(p => p.trim());
+ data.weekday = dateParts[0] || '';
+ data.month = dateParts[1] || '';
+ data.year = dateParts[2] || '';
+ data.date = dateStr;
+ } else if (line.includes('🌡️:')) {
+ // console.log('[RPG Companion] → Matched TEMPERATURE');
+ const tempStr = line.replace('🌡️:', '').trim();
+ data.temperature = tempStr;
+ // Extract numeric value
+ const tempMatch = tempStr.match(/(-?\d+)/);
+ if (tempMatch) {
+ data.tempValue = parseInt(tempMatch[1]);
+ }
+ } else if (line.includes('🕒:')) {
+ // console.log('[RPG Companion] → Matched TIME');
+ const timeStr = line.replace('🕒:', '').trim();
+ data.time = timeStr;
+ // Parse "HH:MM → HH:MM" format
+ const timeParts = timeStr.split('→').map(t => t.trim());
+ data.timeStart = timeParts[0] || '';
+ data.timeEnd = timeParts[1] || '';
+ } else if (line.includes('🗺️:')) {
+ // console.log('[RPG Companion] → Matched LOCATION');
+ data.location = line.replace('🗺️:', '').trim();
+ } else {
+ // Check if it's a weather line
+ // Since \p{Emoji} doesn't work reliably, use a simpler approach
+ const hasColon = line.includes(':');
+ const notInfoBox = !line.includes('Info Box');
+ const notDivider = !line.includes('---');
+ const notCodeFence = !line.trim().startsWith('```');
+
+ // console.log('[RPG Companion] → Checking weather conditions:', {
+ // line: line,
+ // hasColon: hasColon,
+ // notInfoBox: notInfoBox,
+ // notDivider: notDivider
+ // });
+
+ if (hasColon && notInfoBox && notDivider && notCodeFence && line.trim().length > 0) {
+ // Match format: [Weather Emoji]: [Forecast]
+ // Capture everything before colon as emoji, everything after as forecast
+ // console.log('[RPG Companion] → Testing WEATHER match for:', line);
+ const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/);
+ if (weatherMatch) {
+ const potentialEmoji = weatherMatch[1].trim();
+ const forecast = weatherMatch[2].trim();
+
+ // If the first part is short (likely emoji), treat as weather
+ if (potentialEmoji.length <= 5) {
+ data.weatherEmoji = potentialEmoji;
+ data.weatherForecast = forecast;
+ // console.log('[RPG Companion] ✓ Weather parsed:', data.weatherEmoji, data.weatherForecast);
+ } else {
+ // console.log('[RPG Companion] ✗ First part too long for emoji:', potentialEmoji);
+ }
+ } else {
+ // console.log('[RPG Companion] ✗ Weather regex did not match');
+ }
+ } else {
+ // console.log('[RPG Companion] → No match for this line');
+ }
+ }
+ }
+
+ // console.log('[RPG Companion] Parsed Info Box data:', {
+ // date: data.date,
+ // weatherEmoji: data.weatherEmoji,
+ // weatherForecast: data.weatherForecast,
+ // temperature: data.temperature,
+ // timeStart: data.timeStart,
+ // location: data.location
+ // });
+
+ // Build visual dashboard HTML
+ // Row 1: Date, Weather, Temperature, Time widgets
+ let html = '';
+
+ // Calendar widget - always show (editable even if empty)
+ const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON';
+ const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY';
+ const yearDisplay = data.year || 'YEAR';
+ html += `
+
+ `;
+
+ // Weather widget - always show (editable even if empty)
+ const weatherEmoji = data.weatherEmoji || '🌤️';
+ const weatherForecast = data.weatherForecast || 'Weather';
+ html += `
+
+ `;
+
+ // Temperature widget - always show (editable even if empty)
+ const tempDisplay = data.temperature || '20°C';
+ const tempValue = data.tempValue || 20;
+ const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100));
+ const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560';
+ html += `
+
+ `;
+
+ // Time widget - always show (editable even if empty)
+ const timeDisplay = data.timeStart || '12:00';
+ // Parse time for clock hands
+ const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
+ let hourAngle = 0;
+ let minuteAngle = 0;
+ if (timeMatch) {
+ const hours = parseInt(timeMatch[1]);
+ const minutes = parseInt(timeMatch[2]);
+ hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
+ minuteAngle = minutes * 6; // 6° per minute
+ }
+ html += `
+
+ `;
+
+ html += '
';
+
+ // Row 2: Location widget (full width) - always show (editable even if empty)
+ const locationDisplay = data.location || 'Location';
+ html += `
+
+ `;
+
+ $infoBoxContainer.html(html);
+
+ // Add event handlers for editable Info Box fields
+ $infoBoxContainer.find('.rpg-editable').on('blur', function() {
+ const field = $(this).data('field');
+ const value = $(this).text().trim();
+ updateInfoBoxField(field, value);
+ });
+
+ // Remove updating class after animation
+ if (extensionSettings.enableAnimations) {
+ setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
+ }
+}
+
+/**
+ * Updates a specific field in the Info Box data and re-renders.
+ * Handles complex field reconstruction logic for date parts, weather, temperature, time, and location.
+ *
+ * @param {string} field - Field name to update
+ * @param {string} value - New value for the field
+ */
+export function updateInfoBoxField(field, value) {
+ if (!lastGeneratedData.infoBox) {
+ // Initialize with empty info box if it doesn't exist
+ lastGeneratedData.infoBox = 'Info Box\n---\n';
+ }
+
+ // Reconstruct the Info Box text with updated field
+ const lines = lastGeneratedData.infoBox.split('\n');
+ let dateLineFound = false;
+ let dateLineIndex = -1;
+
+ // Find the date line
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].includes('🗓️:')) {
+ dateLineFound = true;
+ dateLineIndex = i;
+ break;
+ }
+ }
+
+ const updatedLines = lines.map((line, index) => {
+ if (field === 'month' && line.includes('🗓️:')) {
+ const parts = line.split(',');
+ if (parts.length >= 2) {
+ // parts[0] = "🗓️: Weekday", parts[1] = " Month", parts[2] = " Year"
+ parts[1] = ' ' + value;
+ return parts.join(',');
+ } else if (parts.length === 1) {
+ // No existing month/year, add them
+ return `${parts[0]}, ${value}, YEAR`;
+ }
+ } else if (field === 'weekday' && line.includes('🗓️:')) {
+ const parts = line.split(',');
+ // Keep the emoji, just update the weekday
+ const month = parts[1] ? parts[1].trim() : 'Month';
+ const year = parts[2] ? parts[2].trim() : 'YEAR';
+ return `🗓️: ${value}, ${month}, ${year}`;
+ } else if (field === 'year' && line.includes('🗓️:')) {
+ const parts = line.split(',');
+ if (parts.length >= 3) {
+ parts[2] = ' ' + value;
+ return parts.join(',');
+ } else if (parts.length === 2) {
+ // No existing year, add it
+ return `${parts[0]}, ${parts[1]}, ${value}`;
+ } else if (parts.length === 1) {
+ // No existing month/year, add them
+ return `${parts[0]}, Month, ${value}`;
+ }
+ } else if (field === 'weatherEmoji' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) {
+ // This is the weather line
+ const parts = line.split(':');
+ if (parts.length >= 2) {
+ return `${value}: ${parts.slice(1).join(':').trim()}`;
+ }
+ } else if (field === 'weatherForecast' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) {
+ // This is the weather line
+ const parts = line.split(':');
+ if (parts.length >= 2) {
+ return `${parts[0].trim()}: ${value}`;
+ }
+ } else if (field === 'temperature' && line.includes('🌡️:')) {
+ return `🌡️: ${value}`;
+ } else if (field === 'timeStart' && line.includes('🕒:')) {
+ // Update time format: "HH:MM → HH:MM"
+ // When user edits, set both start and end time to the new value
+ return `🕒: ${value} → ${value}`;
+ } else if (field === 'location' && line.includes('🗺️:')) {
+ return `🗺️: ${value}`;
+ }
+ return line;
+ });
+
+ // If editing a date field but no date line exists, create one after the divider
+ if ((field === 'month' || field === 'weekday' || field === 'year') && !dateLineFound) {
+ // Find the divider line
+ const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
+ if (dividerIndex >= 0) {
+ // Create initial date line with the edited field
+ let newDateLine = '';
+ if (field === 'weekday') {
+ newDateLine = `🗓️: ${value}, Month, YEAR`;
+ } else if (field === 'month') {
+ newDateLine = `🗓️: Weekday, ${value}, YEAR`;
+ } else if (field === 'year') {
+ newDateLine = `🗓️: Weekday, Month, ${value}`;
+ }
+ // Insert after the divider
+ updatedLines.splice(dividerIndex + 1, 0, newDateLine);
+ }
+ }
+
+ // If editing weather but no weather line exists, create one
+ if ((field === 'weatherEmoji' || field === 'weatherForecast')) {
+ let weatherLineFound = false;
+ for (const line of updatedLines) {
+ // Check if this is a weather line (has emoji and forecast, not one of the special fields)
+ if (line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) {
+ weatherLineFound = true;
+ break;
+ }
+ }
+
+ if (!weatherLineFound) {
+ const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
+ if (dividerIndex >= 0) {
+ let newWeatherLine = '';
+ if (field === 'weatherEmoji') {
+ newWeatherLine = `${value}: Weather`;
+ } else if (field === 'weatherForecast') {
+ newWeatherLine = `🌤️: ${value}`;
+ }
+ // Insert after date line if it exists, otherwise after divider
+ const dateIndex = updatedLines.findIndex(line => line.includes('🗓️:'));
+ const insertIndex = dateIndex >= 0 ? dateIndex + 1 : dividerIndex + 1;
+ updatedLines.splice(insertIndex, 0, newWeatherLine);
+ }
+ }
+ }
+
+ // If editing temperature but no temperature line exists, create one
+ if (field === 'temperature') {
+ const tempLineFound = updatedLines.some(line => line.includes('🌡️:'));
+ if (!tempLineFound) {
+ const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
+ if (dividerIndex >= 0) {
+ const newTempLine = `🌡️: ${value}`;
+ // Find last non-empty line before creating position
+ let insertIndex = dividerIndex + 1;
+ for (let i = 0; i < updatedLines.length; i++) {
+ if (updatedLines[i].includes('🗓️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) {
+ insertIndex = i + 1;
+ }
+ }
+ updatedLines.splice(insertIndex, 0, newTempLine);
+ }
+ }
+ }
+
+ // If editing time but no time line exists, create one
+ if (field === 'timeStart') {
+ const timeLineFound = updatedLines.some(line => line.includes('🕒:'));
+ if (!timeLineFound) {
+ const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
+ if (dividerIndex >= 0) {
+ const newTimeLine = `🕒: ${value} → ${value}`;
+ // Find last non-empty line before creating position
+ let insertIndex = dividerIndex + 1;
+ for (let i = 0; i < updatedLines.length; i++) {
+ if (updatedLines[i].includes('🗓️:') || updatedLines[i].includes('🌡️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) {
+ insertIndex = i + 1;
+ }
+ }
+ updatedLines.splice(insertIndex, 0, newTimeLine);
+ }
+ }
+ }
+
+ // If editing location but no location line exists, create one
+ if (field === 'location') {
+ const locationLineFound = updatedLines.some(line => line.includes('🗺️:'));
+ if (!locationLineFound) {
+ const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
+ if (dividerIndex >= 0) {
+ const newLocationLine = `🗺️: ${value}`;
+ // Insert at the end (before any empty lines)
+ let insertIndex = updatedLines.length;
+ for (let i = updatedLines.length - 1; i >= 0; i--) {
+ if (updatedLines[i].trim() !== '') {
+ insertIndex = i + 1;
+ break;
+ }
+ }
+ updatedLines.splice(insertIndex, 0, newLocationLine);
+ }
+ }
+ }
+
+ lastGeneratedData.infoBox = updatedLines.join('\n');
+
+ // Update the message's swipe data
+ const chat = getContext().chat;
+ if (chat && chat.length > 0) {
+ for (let i = chat.length - 1; i >= 0; i--) {
+ const message = chat[i];
+ if (!message.is_user) {
+ if (message.extra && message.extra.rpg_companion_swipes) {
+ const swipeId = message.swipe_id || 0;
+ if (message.extra.rpg_companion_swipes[swipeId]) {
+ message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
+ // console.log('[RPG Companion] Updated infoBox in message swipe data');
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ saveChatData();
+ renderInfoBox();
+}
diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js
new file mode 100644
index 0000000..03d9eb4
--- /dev/null
+++ b/src/systems/rendering/thoughts.js
@@ -0,0 +1,745 @@
+/**
+ * Character Thoughts Rendering Module
+ * Handles rendering of character thoughts panel and floating thought bubbles in chat
+ */
+
+import { getContext } from '../../../../../../extensions.js';
+import { this_chid, characters } from '../../../../../../../script.js';
+import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
+import {
+ extensionSettings,
+ lastGeneratedData,
+ $thoughtsContainer,
+ FALLBACK_AVATAR_DATA_URI
+} from '../../core/state.js';
+import { saveChatData } from '../../core/persistence.js';
+import { getSafeThumbnailUrl } from '../../utils/avatars.js';
+
+/**
+ * Renders character thoughts (Present Characters) panel.
+ * Displays character cards with avatars, relationship badges, and traits.
+ * Includes event listeners for editable character fields.
+ */
+export function renderThoughts() {
+ if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
+ return;
+ }
+
+ // Add updating class for animation
+ if (extensionSettings.enableAnimations) {
+ $thoughtsContainer.addClass('rpg-content-updating');
+ }
+
+ // Initialize if no data yet
+ if (!lastGeneratedData.characterThoughts) {
+ lastGeneratedData.characterThoughts = '';
+ }
+
+ const lines = lastGeneratedData.characterThoughts.split('\n');
+ const presentCharacters = [];
+
+ // console.log('[RPG Companion] Raw Present Characters:', lastGeneratedData.characterThoughts);
+ // console.log('[RPG Companion] Split into lines:', lines);
+
+ // Parse format: [Emoji]: [Name, Status, Demeanor] | [Relationship] | [Thoughts]
+ for (const line of lines) {
+ // Skip empty lines, headers, dividers, and code fences
+ if (line.trim() &&
+ !line.includes('Present Characters') &&
+ !line.includes('---') &&
+ !line.trim().startsWith('```')) {
+
+ // Match the new format with pipes
+ const parts = line.split('|').map(p => p.trim());
+
+ if (parts.length >= 2) {
+ // First part: [Emoji]: [Name, Status, Demeanor]
+ const firstPart = parts[0].trim();
+ const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
+
+ if (emojiMatch) {
+ const emoji = emojiMatch[1].trim();
+ const info = emojiMatch[2].trim();
+ const relationship = parts[1].trim(); // Enemy/Neutral/Friend/Lover
+ const thoughts = parts[2] ? parts[2].trim() : '';
+
+ // Parse name from info (first part before comma)
+ const infoParts = info.split(',').map(p => p.trim());
+ const name = infoParts[0] || '';
+ const traits = infoParts.slice(1).join(', ');
+
+ if (name && name.toLowerCase() !== 'unavailable') {
+ presentCharacters.push({ emoji, name, traits, relationship, thoughts });
+ // console.log('[RPG Companion] Parsed character:', { name, relationship });
+ }
+ }
+ }
+ }
+ }
+
+ // Relationship status to emoji mapping
+ const relationshipEmojis = {
+ 'Enemy': '⚔️',
+ 'Neutral': '⚖️',
+ 'Friend': '⭐',
+ 'Lover': '❤️'
+ };
+
+ // Build HTML
+ let html = '';
+
+ // console.log('[RPG Companion] Total characters parsed:', presentCharacters.length);
+ // console.log('[RPG Companion] Characters array:', presentCharacters);
+
+ // If no characters parsed, show a placeholder editable card
+ if (presentCharacters.length === 0) {
+ // Get default character portrait (try to use the current character if in 1-on-1 chat)
+ // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
+ let defaultPortrait = FALLBACK_AVATAR_DATA_URI;
+ let defaultName = 'Character';
+
+ if (this_chid !== undefined && characters[this_chid]) {
+ if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') {
+ const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
+ if (thumbnailUrl) {
+ defaultPortrait = thumbnailUrl;
+ }
+ }
+ defaultName = characters[this_chid].name || 'Character';
+ }
+
+ html += '';
+ html += `
+
+
+

+
⚖️
+
+
+
+ `;
+ html += '
';
+ } else {
+ html += '';
+ for (const char of presentCharacters) {
+ // Find character portrait
+ // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
+ let characterPortrait = FALLBACK_AVATAR_DATA_URI;
+
+ // console.log('[RPG Companion] Looking for avatar for:', char.name);
+
+ // For group chats, search through group members first
+ if (selected_group) {
+ const groupMembers = getGroupMembers(selected_group);
+ const matchingMember = groupMembers.find(member =>
+ member && member.name && member.name.toLowerCase() === char.name.toLowerCase()
+ );
+
+ if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
+ const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
+ if (thumbnailUrl) {
+ characterPortrait = thumbnailUrl;
+ }
+ }
+ }
+
+ // For regular chats or if not found in group, search all characters
+ if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) {
+ const matchingCharacter = characters.find(c =>
+ c && c.name && c.name.toLowerCase() === char.name.toLowerCase()
+ );
+
+ if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
+ const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
+ if (thumbnailUrl) {
+ characterPortrait = thumbnailUrl;
+ }
+ }
+ }
+
+ // If this is the current character in a 1-on-1 chat, use their portrait
+ if (this_chid !== undefined && characters[this_chid] &&
+ characters[this_chid].name && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) {
+ const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
+ if (thumbnailUrl) {
+ characterPortrait = thumbnailUrl;
+ }
+ }
+
+ // Get relationship emoji
+ const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️';
+
+ html += `
+
+
+

+
${relationshipEmoji}
+
+
+
+ `;
+ }
+ html += '
';
+ }
+
+ $thoughtsContainer.html(html);
+
+ // Add event handlers for editable character fields
+ $thoughtsContainer.find('.rpg-editable').on('blur', function() {
+ const character = $(this).data('character');
+ const field = $(this).data('field');
+ const value = $(this).text().trim();
+ updateCharacterField(character, field, value);
+ });
+
+ // Remove updating class after animation
+ if (extensionSettings.enableAnimations) {
+ setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600);
+ }
+
+ // Update chat overlay if enabled
+ if (extensionSettings.showThoughtsInChat) {
+ updateChatThoughts();
+ }
+}
+
+/**
+ * Updates a specific character field in Present Characters data and re-renders.
+ * Handles character creation if character doesn't exist yet.
+ *
+ * @param {string} characterName - Name of the character to update
+ * @param {string} field - Field to update (emoji, name, traits, thoughts, relationship)
+ * @param {string} value - New value for the field
+ */
+export function updateCharacterField(characterName, field, value) {
+ // console.log('[RPG Companion] 📝 updateCharacterField called - character:', characterName, 'field:', field, 'value:', value);
+ // console.log('[RPG Companion] 📝 Current lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
+
+ // Initialize if it doesn't exist
+ if (!lastGeneratedData.characterThoughts) {
+ lastGeneratedData.characterThoughts = 'Present Characters\n---\n';
+ }
+
+ const lines = lastGeneratedData.characterThoughts.split('\n');
+ let characterFound = false;
+
+ const updatedLines = lines.map(line => {
+ // Case-insensitive character name matching
+ if (line.toLowerCase().includes(characterName.toLowerCase())) {
+ characterFound = true;
+ const parts = line.split('|').map(p => p.trim());
+ if (parts.length >= 2) {
+ const firstPart = parts[0];
+ const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
+
+ if (emojiMatch) {
+ let emoji = emojiMatch[1].trim();
+ let info = emojiMatch[2].trim();
+ let relationship = parts[1];
+ let thoughts = parts[2] || '';
+
+ const infoParts = info.split(',').map(p => p.trim());
+ let name = infoParts[0];
+ let traits = infoParts.slice(1).join(', ');
+
+ if (field === 'emoji') {
+ emoji = value;
+ } else if (field === 'name') {
+ name = value;
+ } else if (field === 'traits') {
+ traits = value;
+ } else if (field === 'thoughts') {
+ thoughts = value;
+ } else if (field === 'relationship') {
+ const emojiToRelationship = {
+ '⚔️': 'Enemy',
+ '⚖️': 'Neutral',
+ '⭐': 'Friend',
+ '❤️': 'Lover'
+ };
+ relationship = emojiToRelationship[value] || value;
+ }
+
+ const newInfo = traits ? `${name}, ${traits}` : name;
+ return `${emoji}: ${newInfo} | ${relationship} | ${thoughts}`;
+ }
+ }
+ }
+ return line;
+ });
+
+ // If character wasn't found, create a new character line
+ if (!characterFound) {
+ // Find the divider line
+ const dividerIndex = updatedLines.findIndex(line => line.includes('---'));
+ if (dividerIndex >= 0) {
+ // Create initial character line with the edited field
+ let emoji = '😊';
+ let name = characterName;
+ let traits = 'Traits';
+ let relationship = 'Neutral';
+ let thoughts = '';
+
+ // Apply the edited field
+ if (field === 'emoji') {
+ emoji = value;
+ } else if (field === 'name') {
+ name = value;
+ } else if (field === 'traits') {
+ traits = value;
+ } else if (field === 'thoughts') {
+ thoughts = value;
+ } else if (field === 'relationship') {
+ const emojiToRelationship = {
+ '⚔️': 'Enemy',
+ '⚖️': 'Neutral',
+ '⭐': 'Friend',
+ '❤️': 'Lover'
+ };
+ relationship = emojiToRelationship[value] || value;
+ }
+
+ const newCharacterLine = `${emoji}: ${name}, ${traits} | ${relationship} | ${thoughts}`;
+ // Insert after the divider
+ updatedLines.splice(dividerIndex + 1, 0, newCharacterLine);
+ }
+ }
+
+ lastGeneratedData.characterThoughts = updatedLines.join('\n');
+ // console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
+
+ // Also update the last assistant message's swipe data
+ const chat = getContext().chat;
+ if (chat && chat.length > 0) {
+ // Find the last assistant message
+ for (let i = chat.length - 1; i >= 0; i--) {
+ const message = chat[i];
+ if (!message.is_user) {
+ // Found last assistant message - update its swipe data
+ if (message.extra && message.extra.rpg_companion_swipes) {
+ const swipeId = message.swipe_id || 0;
+ if (message.extra.rpg_companion_swipes[swipeId]) {
+ message.extra.rpg_companion_swipes[swipeId].characterThoughts = updatedLines.join('\n');
+ // console.log('[RPG Companion] Updated thoughts in message swipe data');
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ saveChatData();
+
+ // Always update the sidebar panel
+ renderThoughts();
+
+ // For thoughts edited from the bubble, delay recreation to allow blur event to complete
+ // This ensures the edit is saved first, then the bubble is recreated with correct layout
+ if (field === 'thoughts') {
+ setTimeout(() => {
+ updateChatThoughts();
+ }, 100);
+ } else {
+ // For other fields, recreate immediately
+ updateChatThoughts();
+ }
+}
+
+/**
+ * Updates or removes thought overlays in the chat.
+ * Creates floating thought bubbles positioned near character avatars.
+ */
+export function updateChatThoughts() {
+ // console.log('[RPG Companion] ======== updateChatThoughts called ========');
+ // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled);
+ // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat);
+ // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked'));
+ // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
+
+ // Remove existing thought panel and icon
+ $('#rpg-thought-panel').remove();
+ $('#rpg-thought-icon').remove();
+ $('#chat').off('scroll.thoughtPanel');
+ $(window).off('resize.thoughtPanel');
+ $(document).off('click.thoughtPanel');
+
+ // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return
+ if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) {
+ // console.log('[RPG Companion] Thoughts in chat disabled or no data');
+ return;
+ }
+
+ // Parse the Present Characters data to get thoughts
+ const lines = lastGeneratedData.characterThoughts.split('\n');
+ const thoughtsArray = []; // Array of {name, emoji, thought}
+
+ // console.log('[RPG Companion] Parsing thoughts from lines:', lines);
+
+ for (const line of lines) {
+ if (line.trim() &&
+ !line.includes('Present Characters') &&
+ !line.includes('---') &&
+ !line.trim().startsWith('```')) {
+
+ const parts = line.split('|').map(p => p.trim());
+ // console.log('[RPG Companion] Line parts:', parts);
+
+ if (parts.length >= 3) {
+ const firstPart = parts[0].trim();
+ const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/);
+
+ if (emojiMatch) {
+ const emoji = emojiMatch[1].trim();
+ const info = emojiMatch[2].trim();
+ const thoughts = parts[2] ? parts[2].trim() : '';
+
+ const infoParts = info.split(',').map(p => p.trim());
+ const name = infoParts[0] || '';
+
+ // console.log('[RPG Companion] Parsed thought - Name:', name, 'Thought:', thoughts);
+
+ if (name && thoughts && name.toLowerCase() !== 'unavailable') {
+ thoughtsArray.push({ name: name.toLowerCase(), emoji, thought: thoughts });
+ // console.log('[RPG Companion] Added to thoughtsArray:', name.toLowerCase());
+ }
+ }
+ }
+ }
+ }
+
+ // If no thoughts parsed, return
+ if (thoughtsArray.length === 0) {
+ // console.log('[RPG Companion] No thoughts parsed, returning');
+ return;
+ }
+
+ // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length);
+ // console.log('[RPG Companion] Thoughts array:', thoughtsArray);
+
+ // Find the last message to position near
+ const $messages = $('#chat .mes');
+ let $targetMessage = null;
+
+ // Find the most recent non-user message
+ for (let i = $messages.length - 1; i >= 0; i--) {
+ const $message = $messages.eq(i);
+ if ($message.attr('is_user') !== 'true') {
+ $targetMessage = $message;
+ break;
+ }
+ }
+
+ if (!$targetMessage) {
+ // console.log('[RPG Companion] No target message found');
+ return;
+ }
+
+ // Create the thought panel with all thoughts
+ createThoughtPanel($targetMessage, thoughtsArray);
+}
+
+/**
+ * Creates or updates the floating thought panel positioned next to the character's avatar.
+ * Handles responsive positioning for left/right panel modes and mobile viewports.
+ *
+ * @param {jQuery} $message - Message element to position the panel relative to
+ * @param {Array} thoughtsArray - Array of thought objects {name, emoji, thought}
+ */
+export function createThoughtPanel($message, thoughtsArray) {
+ // Remove existing thought panel
+ $('#rpg-thought-panel').remove();
+ $('#rpg-thought-icon').remove();
+
+ // Get the avatar position from the message
+ const $avatar = $message.find('.avatar img');
+ if (!$avatar.length) {
+ // console.log('[RPG Companion] No avatar found in message');
+ return;
+ }
+
+ const avatarRect = $avatar[0].getBoundingClientRect();
+ const panelPosition = extensionSettings.panelPosition;
+ const theme = extensionSettings.theme;
+
+ // Build thought bubbles HTML
+ let thoughtsHtml = '';
+ thoughtsArray.forEach((thought, index) => {
+ thoughtsHtml += `
+
+
+ ${thought.emoji}
+
+
+ ${thought.thought}
+
+
+ `;
+ // Add divider between thoughts (except for last one)
+ if (index < thoughtsArray.length - 1) {
+ thoughtsHtml += '';
+ }
+ });
+
+ // Create the floating thought panel with theme
+ const $thoughtPanel = $(`
+
+
+
+
+ ${thoughtsHtml}
+
+
+ `);
+
+ // Create the collapsed thought icon
+ const $thoughtIcon = $(`
+
+ 💭
+
+ `);
+
+ // Apply custom theme colors if custom theme
+ if (theme === 'custom') {
+ const customStyles = {
+ '--rpg-bg': extensionSettings.customColors.bg,
+ '--rpg-accent': extensionSettings.customColors.accent,
+ '--rpg-text': extensionSettings.customColors.text,
+ '--rpg-highlight': extensionSettings.customColors.highlight
+ };
+ $thoughtPanel.css(customStyles);
+ $thoughtIcon.css(customStyles);
+ }
+
+ // Force a consistent width for the bubble to ensure proper positioning
+ $thoughtPanel.css('width', '350px');
+
+ // Append to body so it's not clipped by chat container
+ $('body').append($thoughtPanel);
+ $('body').append($thoughtIcon);
+
+ // Position the panel next to the avatar
+ const panelWidth = 350;
+ const panelMargin = 20;
+
+ let top = avatarRect.top + (avatarRect.height / 2);
+ let left;
+ let right;
+ let useRightPosition = false;
+ let iconTop = avatarRect.top;
+ let iconLeft;
+
+ // Detect mobile viewport (matches CSS breakpoint)
+ const isMobile = window.innerWidth <= 1000;
+
+ if (isMobile) {
+ // On mobile: position icon horizontally centered on avatar
+ // The CSS transform will shift it upward by 60px
+ iconTop = avatarRect.top; // Start at avatar top (CSS will move it up)
+ iconLeft = avatarRect.left + (avatarRect.width / 2) - 18; // Centered horizontally (18px = half of 36px icon width)
+
+ // Center the thought panel horizontally on mobile
+ left = window.innerWidth / 2 - panelWidth / 2;
+ top = avatarRect.top + avatarRect.height + 60; // Position below icon with spacing
+
+ // No side-specific classes on mobile
+ $thoughtPanel.removeClass('rpg-thought-panel-left rpg-thought-panel-right');
+ $thoughtIcon.removeClass('rpg-thought-icon-left rpg-thought-icon-right');
+
+ console.log('[RPG Companion] Mobile thought icon positioning:', {
+ isMobile,
+ windowWidth: window.innerWidth,
+ avatarLeft: avatarRect.left,
+ avatarWidth: avatarRect.width,
+ iconLeft,
+ iconTop
+ });
+ } else if (panelPosition === 'left') {
+ // Main panel is on left, so thought bubble goes to RIGHT side
+ // Mirror the left side positioning: bubble should be same distance from avatar
+ // but on the opposite side, extending to the right
+ const chatContainer = $('#chat')[0];
+ const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth };
+
+ // Position bubble starting from chat edge, extending right
+ left = chatRect.right + panelMargin; // Start at chat's right edge + margin
+ useRightPosition = false; // Use left positioning so it extends right
+ iconLeft = chatRect.right + 10; // Icon just at the chat edge
+ $thoughtPanel.addClass('rpg-thought-panel-right');
+ $thoughtIcon.addClass('rpg-thought-icon-right');
+
+ // Position circles to flow from left (toward chat/avatar) to right (toward panel)
+ $thoughtPanel.find('.rpg-thought-circles').css({
+ top: 'calc(50% - 50px)',
+ left: '-25px',
+ bottom: 'auto',
+ right: 'auto'
+ });
+ // Mirror the circle flow for right side (left-to-right)
+ $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-start');
+ $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '0' });
+ $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '4px' });
+ $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '8px' });
+ } else {
+ // Main panel is on right, so thought bubble goes on left (near avatar)
+ left = avatarRect.left - panelWidth - panelMargin;
+ iconLeft = avatarRect.left - 40;
+ $thoughtPanel.addClass('rpg-thought-panel-left');
+ $thoughtIcon.addClass('rpg-thought-icon-left');
+
+ // Position circles to flow from avatar (left) to bubble (more left)
+ // Circles should flow right-to-left when bubble is on left
+ $thoughtPanel.find('.rpg-thought-circles').css({
+ top: 'calc(50% - 50px)',
+ right: '-25px',
+ bottom: 'auto',
+ left: 'auto'
+ });
+ // Keep the circle flow for left side (right-to-left) - default from CSS
+ $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-end');
+ $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '0' });
+ $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '4px' });
+ $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '8px' });
+ }
+
+ if (useRightPosition) {
+ $thoughtPanel.css({
+ top: `${top}px`,
+ right: `${right}px`,
+ left: 'auto' // Clear left positioning
+ });
+ } else {
+ $thoughtPanel.css({
+ top: `${top}px`,
+ left: `${left}px`,
+ right: 'auto' // Clear right positioning
+ });
+ }
+
+ $thoughtIcon.css({
+ top: `${iconTop}px`,
+ left: `${iconLeft}px`,
+ right: 'auto' // Clear any right positioning
+ });
+
+ // Initially hide the panel and show the icon
+ $thoughtPanel.hide();
+ $thoughtIcon.show();
+
+ // console.log('[RPG Companion] Thought panel created at:', { top, left });
+
+ // Close button functionality
+ $thoughtPanel.find('.rpg-thought-close').on('click', function(e) {
+ e.stopPropagation();
+ $thoughtPanel.fadeOut(200);
+ $thoughtIcon.fadeIn(200);
+ });
+
+ // Icon click to show panel
+ $thoughtIcon.on('click', function(e) {
+ e.stopPropagation();
+ $thoughtIcon.fadeOut(200);
+ $thoughtPanel.fadeIn(200);
+ });
+
+ // Add event handlers for editable thoughts in the bubble
+ $thoughtPanel.find('.rpg-editable').on('blur', function() {
+ const character = $(this).data('character');
+ const field = $(this).data('field');
+ const value = $(this).text().trim();
+ // console.log('[RPG Companion] 💭 Thought bubble blur event - character:', character, 'field:', field, 'value:', value);
+ updateCharacterField(character, field, value);
+ });
+
+ // RAF throttling for smooth position updates
+ let positionUpdateRaf = null;
+
+ // Update position on scroll with RAF throttling
+ const updatePanelPosition = () => {
+ if (!$message.is(':visible')) {
+ $thoughtPanel.hide();
+ $thoughtIcon.hide();
+ return;
+ }
+
+ // Cancel any pending RAF
+ if (positionUpdateRaf) {
+ cancelAnimationFrame(positionUpdateRaf);
+ }
+
+ // Schedule update on next frame
+ positionUpdateRaf = requestAnimationFrame(() => {
+ const newAvatarRect = $avatar[0].getBoundingClientRect();
+ const newTop = newAvatarRect.top + (newAvatarRect.height / 2);
+ const newIconTop = newAvatarRect.top;
+ let newLeft, newIconLeft;
+
+ if (panelPosition === 'left') {
+ // Position at chat's right edge, extending right
+ const chatContainer = $('#chat')[0];
+ const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth };
+ newLeft = chatRect.right + panelMargin;
+ newIconLeft = chatRect.right + 10;
+
+ $thoughtPanel.css({
+ top: `${newTop}px`,
+ left: `${newLeft}px`,
+ right: 'auto'
+ });
+ } else {
+ // Left position relative to avatar
+ newLeft = newAvatarRect.left - panelWidth - panelMargin;
+ newIconLeft = newAvatarRect.left - 40;
+
+ $thoughtPanel.css({
+ top: `${newTop}px`,
+ left: `${newLeft}px`,
+ right: 'auto'
+ });
+ }
+
+ $thoughtIcon.css({
+ top: `${newIconTop}px`,
+ left: `${newIconLeft}px`,
+ right: 'auto'
+ });
+
+ if ($thoughtPanel.is(':visible')) {
+ $thoughtPanel.show();
+ }
+ if ($thoughtIcon.is(':visible')) {
+ $thoughtIcon.show();
+ }
+
+ positionUpdateRaf = null;
+ });
+ };
+
+ // Update position on scroll and resize
+ $('#chat').on('scroll.thoughtPanel', updatePanelPosition);
+ $(window).on('resize.thoughtPanel', updatePanelPosition);
+
+ // Remove panel when clicking outside (but not when clicking icon or panel)
+ $(document).on('click.thoughtPanel', function(e) {
+ if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) {
+ // Hide the panel and show the icon instead of removing
+ $thoughtPanel.fadeOut(200);
+ $thoughtIcon.fadeIn(200);
+ }
+ });
+}
diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js
new file mode 100644
index 0000000..78d7ea6
--- /dev/null
+++ b/src/systems/rendering/userStats.js
@@ -0,0 +1,242 @@
+/**
+ * User Stats Rendering Module
+ * Handles rendering of the user stats panel with progress bars and classic RPG stats
+ */
+
+import { getContext } from '../../../../../../extensions.js';
+import { user_avatar } from '../../../../../../../script.js';
+import {
+ extensionSettings,
+ lastGeneratedData,
+ $userStatsContainer,
+ FALLBACK_AVATAR_DATA_URI
+} from '../../core/state.js';
+import {
+ saveSettings,
+ saveChatData,
+ updateMessageSwipeData
+} from '../../core/persistence.js';
+import { getSafeThumbnailUrl } from '../../utils/avatars.js';
+
+/**
+ * Renders the user stats panel with health bars, mood, inventory, and classic stats.
+ * Includes event listeners for editable fields.
+ */
+export function renderUserStats() {
+ if (!extensionSettings.showUserStats || !$userStatsContainer) {
+ return;
+ }
+
+ const stats = extensionSettings.userStats;
+ const userName = getContext().name1;
+
+ // Initialize lastGeneratedData.userStats if it doesn't exist
+ if (!lastGeneratedData.userStats) {
+ lastGeneratedData.userStats = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\nInventory: ${stats.inventory}`;
+ }
+
+ // Get user portrait - handle both default-user and custom persona folders
+ // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
+ let userPortrait = FALLBACK_AVATAR_DATA_URI;
+
+ if (user_avatar) {
+ // Try to get the thumbnail using our safe helper
+ const thumbnailUrl = getSafeThumbnailUrl('persona', user_avatar);
+ if (thumbnailUrl) {
+ userPortrait = thumbnailUrl;
+ }
+ }
+
+ // Create gradient from low to high color
+ const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`;
+
+ const html = `
+
+
+
+

+
+
+ ${stats.inventory || 'None'}
+
+
+
+
+
+
Health:
+
+
${stats.health}%
+
+
+
+
Satiety:
+
+
${stats.satiety}%
+
+
+
+
Energy:
+
+
${stats.energy}%
+
+
+
+
Hygiene:
+
+
${stats.hygiene}%
+
+
+
+
Arousal:
+
+
${stats.arousal}%
+
+
+
+
+
${stats.mood}
+
${stats.conditions}
+
+
+
+
+
+
+
+
STR
+
+
+ ${extensionSettings.classicStats.str}
+
+
+
+
+
DEX
+
+
+ ${extensionSettings.classicStats.dex}
+
+
+
+
+
CON
+
+
+ ${extensionSettings.classicStats.con}
+
+
+
+
+
INT
+
+
+ ${extensionSettings.classicStats.int}
+
+
+
+
+
WIS
+
+
+ ${extensionSettings.classicStats.wis}
+
+
+
+
+
CHA
+
+
+ ${extensionSettings.classicStats.cha}
+
+
+
+
+
+
+
+ `;
+
+ $userStatsContainer.html(html);
+
+ // Add event listeners for editable stat values
+ $('.rpg-editable-stat').on('blur', function() {
+ const field = $(this).data('field');
+ const textValue = $(this).text().replace('%', '').trim();
+ let value = parseInt(textValue);
+
+ // Validate and clamp value between 0 and 100
+ if (isNaN(value)) {
+ value = 0;
+ }
+ value = Math.max(0, Math.min(100, value));
+
+ // Update the setting
+ extensionSettings.userStats[field] = value;
+
+ // Also update lastGeneratedData to keep it in sync
+ if (!lastGeneratedData.userStats) {
+ lastGeneratedData.userStats = '';
+ }
+ // Regenerate the userStats text with updated value
+ const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
+ lastGeneratedData.userStats = statsText;
+
+ saveSettings();
+ saveChatData();
+ updateMessageSwipeData();
+
+ // Re-render to update the bar
+ renderUserStats();
+ });
+
+ // Add event listener for inventory editing
+ $('.rpg-inventory-items.rpg-editable').on('blur', function() {
+ const value = $(this).text().trim();
+ extensionSettings.userStats.inventory = value || 'None';
+
+ // Update lastGeneratedData
+ const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
+ lastGeneratedData.userStats = statsText;
+
+ saveSettings();
+ saveChatData();
+ updateMessageSwipeData();
+ });
+
+ // Add event listeners for mood/conditions editing
+ $('.rpg-mood-emoji.rpg-editable').on('blur', function() {
+ const value = $(this).text().trim();
+ extensionSettings.userStats.mood = value || '😐';
+
+ // Update lastGeneratedData
+ const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
+ lastGeneratedData.userStats = statsText;
+
+ saveSettings();
+ saveChatData();
+ updateMessageSwipeData();
+ });
+
+ $('.rpg-mood-conditions.rpg-editable').on('blur', function() {
+ const value = $(this).text().trim();
+ extensionSettings.userStats.conditions = value || 'None';
+
+ // Update lastGeneratedData
+ const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`;
+ lastGeneratedData.userStats = statsText;
+
+ saveSettings();
+ saveChatData();
+ updateMessageSwipeData();
+ });
+}
diff --git a/src/utils/avatars.js b/src/utils/avatars.js
new file mode 100644
index 0000000..382870c
--- /dev/null
+++ b/src/utils/avatars.js
@@ -0,0 +1,46 @@
+/**
+ * Avatar Utilities Module
+ * Handles safe avatar/thumbnail URL generation with error handling
+ */
+
+import { getThumbnailUrl } from '../../../../../../script.js';
+
+/**
+ * Safely retrieves a thumbnail URL from SillyTavern's API with error handling.
+ * Returns null instead of throwing errors to prevent extension crashes.
+ *
+ * @param {string} type - Type of thumbnail ('avatar' or 'persona')
+ * @param {string} filename - Filename of the avatar/persona
+ * @returns {string|null} Thumbnail URL or null if unavailable/error
+ */
+export function getSafeThumbnailUrl(type, filename) {
+ // Return null if no filename provided
+ if (!filename || filename === 'none') {
+ console.log(`[RPG Companion] No valid filename provided for ${type} thumbnail`);
+ return null;
+ }
+
+ try {
+ // Attempt to get thumbnail URL from SillyTavern API
+ const url = getThumbnailUrl(type, filename);
+
+ // Validate that we got a string back
+ if (typeof url !== 'string' || url.trim() === '') {
+ console.warn(`[RPG Companion] getThumbnailUrl returned invalid result for ${type}:`, filename);
+ return null;
+ }
+
+ console.log(`[RPG Companion] Successfully generated ${type} thumbnail URL for: ${filename}`);
+ return url;
+ } catch (error) {
+ // Log detailed error information for debugging
+ console.error(`[RPG Companion] Failed to get ${type} thumbnail for "${filename}":`, error);
+ console.error('[RPG Companion] Error details:', {
+ type,
+ filename,
+ errorMessage: error.message,
+ errorStack: error.stack
+ });
+ return null;
+ }
+}