refactor: extract rendering systems
Extract rendering logic from index.js into modular system: - src/utils/avatars.js: Safe thumbnail URL generation with error handling - src/systems/rendering/userStats.js: User stats panel with progress bars and classic RPG stats - src/systems/rendering/infoBox.js: Info box dashboard with weather, date, time, and location widgets - src/systems/rendering/thoughts.js: Character thoughts panel and floating chat bubbles Reduces index.js from 3,829 to 2,430 lines (-1,399 lines, -36.5%) All rendering functions now properly modularized with full JSDoc documentation Event listeners preserved in render functions for interactive fields
This commit is contained in:
@@ -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 = `
|
||||
<div class="rpg-dashboard rpg-dashboard-row-1">
|
||||
<div class="rpg-dashboard-widget rpg-placeholder-widget">
|
||||
<div class="rpg-placeholder-text">No data yet</div>
|
||||
<div class="rpg-placeholder-hint">Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$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 = '<div class="rpg-dashboard rpg-dashboard-row-1">';
|
||||
|
||||
// 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 += `
|
||||
<div class="rpg-dashboard-widget rpg-calendar-widget">
|
||||
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" title="Click to edit">${monthShort}</div>
|
||||
<div class="rpg-calendar-day rpg-editable" contenteditable="true" data-field="weekday" title="Click to edit">${weekdayShort}</div>
|
||||
<div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" title="Click to edit">${yearDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Weather widget - always show (editable even if empty)
|
||||
const weatherEmoji = data.weatherEmoji || '🌤️';
|
||||
const weatherForecast = data.weatherForecast || 'Weather';
|
||||
html += `
|
||||
<div class="rpg-dashboard-widget rpg-weather-widget">
|
||||
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="Click to edit emoji">${weatherEmoji}</div>
|
||||
<div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="Click to edit">${weatherForecast}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 += `
|
||||
<div class="rpg-dashboard-widget rpg-temp-widget">
|
||||
<div class="rpg-thermometer">
|
||||
<div class="rpg-thermometer-bulb"></div>
|
||||
<div class="rpg-thermometer-tube">
|
||||
<div class="rpg-thermometer-fill" style="height: ${tempPercent}%; background: ${tempColor}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-temp-value rpg-editable" contenteditable="true" data-field="temperature" title="Click to edit">${tempDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 += `
|
||||
<div class="rpg-dashboard-widget rpg-clock-widget">
|
||||
<div class="rpg-clock">
|
||||
<div class="rpg-clock-face">
|
||||
<div class="rpg-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
|
||||
<div class="rpg-clock-minute" style="transform: rotate(${minuteAngle}deg)"></div>
|
||||
<div class="rpg-clock-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="Click to edit">${timeDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Row 2: Location widget (full width) - always show (editable even if empty)
|
||||
const locationDisplay = data.location || 'Location';
|
||||
html += `
|
||||
<div class="rpg-dashboard rpg-dashboard-row-2">
|
||||
<div class="rpg-dashboard-widget rpg-location-widget">
|
||||
<div class="rpg-map-bg">
|
||||
<div class="rpg-map-marker">📍</div>
|
||||
</div>
|
||||
<div class="rpg-location-text rpg-editable" contenteditable="true" data-field="location" title="Click to edit">${locationDisplay}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$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();
|
||||
}
|
||||
@@ -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 += '<div class="rpg-thoughts-content">';
|
||||
html += `
|
||||
<div class="rpg-character-card" data-character-name="${defaultName}">
|
||||
<div class="rpg-character-avatar">
|
||||
<img src="${defaultPortrait}" alt="${defaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">⚖️</div>
|
||||
</div>
|
||||
<div class="rpg-character-info">
|
||||
<div class="rpg-character-header">
|
||||
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="emoji" title="Click to edit emoji">😊</span>
|
||||
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="name" title="Click to edit name">${defaultName}</span>
|
||||
</div>
|
||||
<div class="rpg-character-traits rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="traits" title="Click to edit traits">Traits</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="rpg-thoughts-content">';
|
||||
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 += `
|
||||
<div class="rpg-character-card" data-character-name="${char.name}">
|
||||
<div class="rpg-character-avatar">
|
||||
<img src="${characterPortrait}" alt="${char.name}" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${char.name}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipEmoji}</div>
|
||||
</div>
|
||||
<div class="rpg-character-info">
|
||||
<div class="rpg-character-header">
|
||||
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${char.name}" data-field="emoji" title="Click to edit emoji">${char.emoji}</span>
|
||||
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${char.name}" data-field="name" title="Click to edit name">${char.name}</span>
|
||||
</div>
|
||||
<div class="rpg-character-traits rpg-editable" contenteditable="true" data-character="${char.name}" data-field="traits" title="Click to edit traits">${char.traits}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
$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 += `
|
||||
<div class="rpg-thought-item">
|
||||
<div class="rpg-thought-emoji-box">
|
||||
${thought.emoji}
|
||||
</div>
|
||||
<div class="rpg-thought-content rpg-editable" contenteditable="true" data-character="${thought.name}" data-field="thoughts" title="Click to edit thoughts">
|
||||
${thought.thought}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// Add divider between thoughts (except for last one)
|
||||
if (index < thoughtsArray.length - 1) {
|
||||
thoughtsHtml += '<div class="rpg-thought-divider"></div>';
|
||||
}
|
||||
});
|
||||
|
||||
// Create the floating thought panel with theme
|
||||
const $thoughtPanel = $(`
|
||||
<div id="rpg-thought-panel" class="rpg-thought-panel" data-theme="${theme}">
|
||||
<button class="rpg-thought-close" title="Hide thoughts">×</button>
|
||||
<div class="rpg-thought-circles">
|
||||
<div class="rpg-thought-circle rpg-circle-1"></div>
|
||||
<div class="rpg-thought-circle rpg-circle-2"></div>
|
||||
<div class="rpg-thought-circle rpg-circle-3"></div>
|
||||
</div>
|
||||
<div class="rpg-thought-bubble">
|
||||
${thoughtsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Create the collapsed thought icon
|
||||
const $thoughtIcon = $(`
|
||||
<div id="rpg-thought-icon" class="rpg-thought-icon" data-theme="${theme}" title="Show thoughts">
|
||||
💭
|
||||
</div>
|
||||
`);
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="rpg-stats-content">
|
||||
<div class="rpg-stats-left">
|
||||
<div style="display: flex; gap: clamp(4px, 0.8vh, 8px); align-items: center; flex-shrink: 0;">
|
||||
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||
<div class="rpg-inventory-box">
|
||||
<div class="rpg-inventory-items rpg-editable" contenteditable="true" data-field="inventory" title="Click to edit">
|
||||
${stats.inventory || 'None'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-stats-grid">
|
||||
<div class="rpg-stat-row">
|
||||
<span class="rpg-stat-label">Health:</span>
|
||||
<div class="rpg-stat-bar" style="background: ${gradient}">
|
||||
<div class="rpg-stat-fill" style="width: ${100 - stats.health}%"></div>
|
||||
</div>
|
||||
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="health" title="Click to edit">${stats.health}%</span>
|
||||
</div>
|
||||
|
||||
<div class="rpg-stat-row">
|
||||
<span class="rpg-stat-label">Satiety:</span>
|
||||
<div class="rpg-stat-bar" style="background: ${gradient}">
|
||||
<div class="rpg-stat-fill" style="width: ${100 - stats.satiety}%"></div>
|
||||
</div>
|
||||
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="satiety" title="Click to edit">${stats.satiety}%</span>
|
||||
</div>
|
||||
|
||||
<div class="rpg-stat-row">
|
||||
<span class="rpg-stat-label">Energy:</span>
|
||||
<div class="rpg-stat-bar" style="background: ${gradient}">
|
||||
<div class="rpg-stat-fill" style="width: ${100 - stats.energy}%"></div>
|
||||
</div>
|
||||
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="energy" title="Click to edit">${stats.energy}%</span>
|
||||
</div>
|
||||
|
||||
<div class="rpg-stat-row">
|
||||
<span class="rpg-stat-label">Hygiene:</span>
|
||||
<div class="rpg-stat-bar" style="background: ${gradient}">
|
||||
<div class="rpg-stat-fill" style="width: ${100 - stats.hygiene}%"></div>
|
||||
</div>
|
||||
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="hygiene" title="Click to edit">${stats.hygiene}%</span>
|
||||
</div>
|
||||
|
||||
<div class="rpg-stat-row">
|
||||
<span class="rpg-stat-label">Arousal:</span>
|
||||
<div class="rpg-stat-bar" style="background: ${gradient}">
|
||||
<div class="rpg-stat-fill" style="width: ${100 - stats.arousal}%"></div>
|
||||
</div>
|
||||
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="arousal" title="Click to edit">${stats.arousal}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rpg-mood">
|
||||
<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>
|
||||
<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${stats.conditions}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rpg-stats-right">
|
||||
<div class="rpg-classic-stats">
|
||||
<div class="rpg-classic-stats-grid">
|
||||
<div class="rpg-classic-stat" data-stat="str">
|
||||
<span class="rpg-classic-stat-label">STR</span>
|
||||
<div class="rpg-classic-stat-buttons">
|
||||
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="str">−</button>
|
||||
<span class="rpg-classic-stat-value">${extensionSettings.classicStats.str}</span>
|
||||
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="str">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-classic-stat" data-stat="dex">
|
||||
<span class="rpg-classic-stat-label">DEX</span>
|
||||
<div class="rpg-classic-stat-buttons">
|
||||
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="dex">−</button>
|
||||
<span class="rpg-classic-stat-value">${extensionSettings.classicStats.dex}</span>
|
||||
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="dex">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-classic-stat" data-stat="con">
|
||||
<span class="rpg-classic-stat-label">CON</span>
|
||||
<div class="rpg-classic-stat-buttons">
|
||||
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="con">−</button>
|
||||
<span class="rpg-classic-stat-value">${extensionSettings.classicStats.con}</span>
|
||||
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="con">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-classic-stat" data-stat="int">
|
||||
<span class="rpg-classic-stat-label">INT</span>
|
||||
<div class="rpg-classic-stat-buttons">
|
||||
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="int">−</button>
|
||||
<span class="rpg-classic-stat-value">${extensionSettings.classicStats.int}</span>
|
||||
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="int">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-classic-stat" data-stat="wis">
|
||||
<span class="rpg-classic-stat-label">WIS</span>
|
||||
<div class="rpg-classic-stat-buttons">
|
||||
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="wis">−</button>
|
||||
<span class="rpg-classic-stat-value">${extensionSettings.classicStats.wis}</span>
|
||||
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="wis">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpg-classic-stat" data-stat="cha">
|
||||
<span class="rpg-classic-stat-label">CHA</span>
|
||||
<div class="rpg-classic-stat-buttons">
|
||||
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="cha">−</button>
|
||||
<span class="rpg-classic-stat-value">${extensionSettings.classicStats.cha}</span>
|
||||
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="cha">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user