`;
}
// Time widget - clock visual
if (data.timeStart) {
// Parse time for clock hands
const timeMatch = data.timeStart.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 += `
${data.timeStart}
`;
}
html += '
';
// Row 2: Location widget (full width)
if (data.location) {
html += `
π
${data.location}
`;
}
$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');
}
// If no data yet, show placeholder
if (!lastGeneratedData.characterThoughts) {
const placeholderHtml = `
No characters detected
Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button
`;
$thoughtsContainer.html(placeholderHtml);
if (extensionSettings.enableAnimations) {
setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600);
}
return;
}
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 (presentCharacters.length === 0) {
html += '
Unavailable
';
} else {
html += '
';
for (const char of presentCharacters) {
// Find character portrait
let characterPortrait = 'img/user-default.png';
// 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') {
characterPortrait = getThumbnailUrl('avatar', matchingMember.avatar);
}
}
// For regular chats or if not found in group, search all characters
if (characterPortrait === 'img/user-default.png' && 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') {
characterPortrait = getThumbnailUrl('avatar', matchingCharacter.avatar);
}
}
// 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()) {
characterPortrait = getThumbnailUrl('avatar', characters[this_chid].avatar);
}
// Get relationship emoji
const relationshipEmoji = relationshipEmojis[char.relationship] || 'βοΈ';
html += `
${relationshipEmoji}
${char.emoji}${char.name}
${char.traits}
`;
}
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) return;
// Reconstruct the Info Box text with updated field
const lines = lastGeneratedData.infoBox.split('\n');
const updatedLines = lines.map(line => {
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 (field === 'weekday' && line.includes('ποΈ:')) {
const parts = line.split(',');
if (parts.length >= 1) {
// Keep the emoji, just update the weekday
parts[0] = 'ποΈ: ' + value;
return parts.join(',');
}
} else if (field === 'year' && line.includes('ποΈ:')) {
const parts = line.split(',');
if (parts.length >= 3) {
parts[2] = ' ' + value;
return parts.join(',');
}
} 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"
const currentTime = line.replace('π:', '').trim();
const timeParts = currentTime.split('β').map(t => t.trim());
const timeEnd = timeParts[1] || timeParts[0]; // Keep end time or use start as both
return `π: ${value} β ${timeEnd}`;
} else if (field === 'location' && line.includes('πΊοΈ:')) {
return `πΊοΈ: ${value}`;
}
return line;
});
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);
if (!lastGeneratedData.characterThoughts) return; const lines = lastGeneratedData.characterThoughts.split('\n');
const updatedLines = lines.map(line => {
// Case-insensitive character name matching
if (line.toLowerCase().includes(characterName.toLowerCase())) {
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;
});
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;
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 icon
$thoughtIcon.hide();
// 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);
});
// Update position on scroll
const updatePanelPosition = () => {
if (!$message.is(':visible')) {
$thoughtPanel.hide();
$thoughtIcon.hide();
return;
}
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();
}
};
// 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);
}
});
}
/**
* Event handler for when generation is about to start (TOGETHER MODE).
* Injects RPG tracking prompt into the generation.
*/
function onGenerationStarted() {
// console.log('[RPG Companion] onGenerationStarted called');
// console.log('[RPG Companion] enabled:', extensionSettings.enabled);
// console.log('[RPG Companion] generationMode:', extensionSettings.generationMode);
// console.log('[RPG Companion] β‘ EVENT: onGenerationStarted - lastActionWasSwipe =', lastActionWasSwipe, '| isGenerating =', isGenerating);
if (!extensionSettings.enabled) {
return;
}
const chat = getContext().chat;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// For SEPARATE mode only: Check if we need to commit extension data
// BUT: Only do this for the MAIN generation, not the tracker update generation
// If isGenerating is true, this is the tracker update generation (second call), so skip flag logic
// console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts);
if (extensionSettings.generationMode === 'separate' && !isGenerating) {
if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] π COMMIT: New message - committing lastGeneratedData');
// console.log('[RPG Companion] BEFORE commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] AFTER commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after committing (ready for next cycle)
} else {
// console.log('[RPG Companion] π SWIPE: Using existing committedTrackerData (no commit)');
// console.log('[RPG Companion] committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after using it (swipe generation complete, ready for next action)
}
}
// Use the committed tracker data as source for generation
// console.log('[RPG Companion] Using committedTrackerData for generation');
// console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats);
// Parse stats from committed data to update the extensionSettings for prompt generation
if (committedTrackerData.userStats) {
// console.log('[RPG Companion] Parsing committed userStats into extensionSettings');
parseUserStats(committedTrackerData.userStats);
// console.log('[RPG Companion] After parsing, extensionSettings.userStats:', JSON.stringify(extensionSettings.userStats));
}
if (extensionSettings.generationMode === 'together') {
// console.log('[RPG Companion] In together mode, generating prompts...');
const example = generateTrackerExample();
const instructions = generateTrackerInstructions();
// console.log('[RPG Companion] Example:', example ? 'exists' : 'empty');
// console.log('[RPG Companion] Chat length:', chat ? chat.length : 'chat is null');
// Find the last assistant message in the chat history
let lastAssistantDepth = -1; // -1 means not found
if (chat && chat.length > 0) {
// console.log('[RPG Companion] Searching for last assistant message...');
// Start from depth 1 (skip depth 0 which is usually user's message or prefill)
for (let depth = 1; depth < chat.length; depth++) {
const index = chat.length - 1 - depth; // Convert depth to index
const message = chat[index];
// console.log('[RPG Companion] Checking depth', depth, 'index', index, 'message properties:', Object.keys(message));
// Check for assistant message: not user and not system
if (!message.is_user && !message.is_system) {
// Found assistant message at this depth
// Inject at the SAME depth to prepend to this assistant message
lastAssistantDepth = depth;
// console.log('[RPG Companion] Found last assistant message at depth', depth, '-> injecting at same depth:', lastAssistantDepth);
break;
}
}
}
// If we have previous tracker data and found an assistant message, inject it as an assistant message
if (example && lastAssistantDepth > 0) {
setExtensionPrompt('rpg-companion-example', example, extension_prompt_types.IN_CHAT, lastAssistantDepth, false, extension_prompt_roles.ASSISTANT);
// console.log('[RPG Companion] Injected tracker example as assistant message at depth:', lastAssistantDepth);
} else {
// console.log('[RPG Companion] NOT injecting example. example:', !!example, 'lastAssistantDepth:', lastAssistantDepth);
}
// Inject the instructions as a user message at depth 0 (right before generation)
setExtensionPrompt('rpg-companion-inject', instructions, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
// console.log('[RPG Companion] Injected RPG tracking instructions at depth 0 (right before generation)');
} else if (extensionSettings.generationMode === 'separate') {
// In SEPARATE mode, inject the contextual summary for main roleplay generation
const contextSummary = generateContextualSummary();
if (contextSummary) {
const wrappedContext = `Here is context information about the current scene, and what follows is the last message in the chat history:
${contextSummary}
Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses.
`;
// Inject context at depth 1 (before last user message) as SYSTEM
setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false);
// console.log('[RPG Companion] Injected contextual summary for separate mode:', contextSummary);
} else {
// Clear if no data yet
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
// Inject HTML prompt separately at depth 0 if enabled (same as together mode pattern)
if (extensionSettings.enableHtmlPrompt) {
const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response:
- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on.
- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible.
- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries.
- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application.
- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate mode');
} else {
// Clear HTML prompt if disabled
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Clear together mode injections
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
} else {
// Clear all injections
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
}
/**
* Commits the tracker data from the last assistant message to be used as source for next generation.
* This should be called when the user has replied to a message, ensuring all swipes of the next
* response use the same committed context.
*/
function commitTrackerData() {
const chat = getContext().chat;
if (!chat || chat.length === 0) {
return;
}
// 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 - commit its tracker data
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
const swipeData = message.extra.rpg_companion_swipes[swipeId];
if (swipeData) {
// console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId);
committedTrackerData.userStats = swipeData.userStats || null;
committedTrackerData.infoBox = swipeData.infoBox || null;
committedTrackerData.characterThoughts = swipeData.characterThoughts || null;
} else {
// console.log('[RPG Companion] No swipe data found for swipe', swipeId);
}
} else {
// console.log('[RPG Companion] No RPG data found in last assistant message');
}
break;
}
}
}
/**
* Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe.
*/
function onMessageSent() {
if (!extensionSettings.enabled) return;
// User sent a new message - NOT a swipe
lastActionWasSwipe = false;
// console.log('[RPG Companion] π’ EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe);
}
/**
* Event handler for when a message is generated.
*/
async function onMessageReceived(data) {
if (!extensionSettings.enabled) {
return;
}
if (extensionSettings.generationMode === 'together') {
// In together mode, parse the response to extract RPG data
// The message should be in chat[chat.length - 1]
const lastMessage = chat[chat.length - 1];
if (lastMessage && !lastMessage.is_user) {
const responseText = lastMessage.mes;
// console.log('[RPG Companion] Parsing together mode response:', responseText);
const parsedData = parseResponse(responseText);
// Update stored data
if (parsedData.userStats) {
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
}
if (parsedData.infoBox) {
lastGeneratedData.infoBox = parsedData.infoBox;
}
if (parsedData.characterThoughts) {
lastGeneratedData.characterThoughts = parsedData.characterThoughts;
}
// Store RPG data for this specific swipe in the message's extra field
if (!lastMessage.extra) {
lastMessage.extra = {};
}
if (!lastMessage.extra.rpg_companion_swipes) {
lastMessage.extra.rpg_companion_swipes = {};
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId);
// If there's no committed data yet (first time generating), automatically commit
if (!committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedData.characterThoughts;
// console.log('[RPG Companion] π FIRST TIME: Auto-committed tracker data');
} else {
// console.log('[RPG Companion] Data will be committed when user replies');
}
// Remove the tracker code blocks from the visible message
let cleanedMessage = responseText;
// Remove all code blocks that contain tracker data
cleanedMessage = cleanedMessage.replace(/```[^`]*?Stats\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Info Box\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Present Characters\s*\n\s*---[^`]*?```\s*/gi, '');
// Remove any stray "---" dividers that might appear after the code blocks
cleanedMessage = cleanedMessage.replace(/^\s*---\s*$/gm, '');
// Clean up multiple consecutive newlines
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, '\n\n');
// Update the message in chat history
lastMessage.mes = cleanedMessage.trim();
// Update the swipe text as well
if (lastMessage.swipes && lastMessage.swipes[currentSwipeId] !== undefined) {
lastMessage.swipes[currentSwipeId] = cleanedMessage.trim();
}
// console.log('[RPG Companion] Cleaned message, removed tracker code blocks');
// Render the updated data
renderUserStats();
renderInfoBox();
renderThoughts();
// Save to chat metadata
saveChatData();
}
} else if (extensionSettings.generationMode === 'separate' && extensionSettings.autoUpdate) {
// In separate mode with auto-update, trigger update after message
setTimeout(async () => {
await updateRPGData();
}, 500);
}
// Reset the swipe flag after generation completes
// This ensures that if the user swiped β auto-reply generated β flag is now cleared
// so the next user message will be treated as a new message (not a swipe)
if (lastActionWasSwipe) {
// console.log('[RPG Companion] π Generation complete after swipe - resetting lastActionWasSwipe to false');
lastActionWasSwipe = false;
}
}
/**
* Event handler for character change.
*/
function onCharacterChanged() {
// Remove thought panel and icon when changing characters
$('#rpg-thought-panel').remove();
$('#rpg-thought-icon').remove();
$('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel');
// Load chat-specific data when switching chats
loadChatData();
// Commit tracker data from the last assistant message to initialize for this chat
commitTrackerData();
// Re-render with the loaded data
renderUserStats();
renderInfoBox();
renderThoughts();
// Update chat thought overlays
updateChatThoughts();
}
/**
* Event handler for when a message is swiped.
* Loads the RPG data for the swipe the user navigated to.
*/
function onMessageSwiped(messageIndex) {
if (!extensionSettings.enabled) {
return;
}
// console.log('[RPG Companion] Message swiped at index:', messageIndex);
// Get the message that was swiped
const message = chat[messageIndex];
if (!message || message.is_user) {
return;
}
const currentSwipeId = message.swipe_id || 0;
// Only set flag to true if this swipe will trigger a NEW generation
// Check if the swipe already exists (has content in the swipes array)
const isExistingSwipe = message.swipes &&
message.swipes[currentSwipeId] !== undefined &&
message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0;
if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation
lastActionWasSwipe = true;
// console.log('[RPG Companion] π΅ EVENT: onMessageSwiped (NEW generation) - lastActionWasSwipe =', lastActionWasSwipe);
} else {
// This is navigating to an EXISTING swipe - don't change the flag
// console.log('[RPG Companion] π΅ EVENT: onMessageSwiped (existing swipe navigation) - lastActionWasSwipe unchanged =', lastActionWasSwipe);
}
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load RPG data for this swipe into lastGeneratedData (for display only)
// This updates what the user sees, but does NOT commit it
// Committed data will be updated when/if the user replies to this swipe
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Update display data
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Parse user stats if available
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
// console.log('[RPG Companion] Loaded RPG data for swipe', currentSwipeId, '(display only, NOT committed)');
// console.log('[RPG Companion] committedTrackerData unchanged - will be updated if user replies to this swipe');
} else {
// No data for this swipe - keep existing lastGeneratedData (don't clear it)
// This ensures the display remains consistent and data is available for next commit
// console.log('[RPG Companion] No RPG data for swipe', currentSwipeId, '- keeping existing lastGeneratedData');
}
// Re-render the panels (display only - committedTrackerData unchanged)
renderUserStats();
renderInfoBox();
renderThoughts();
// Update chat thought overlays
updateChatThoughts();
}
/**
* Automatically imports the HTML cleaning regex script if it doesn't already exist.
* This regex removes HTML tags from outgoing prompts to prevent formatting issues.
*/
async function ensureHtmlCleaningRegex() {
try {
// Import the regex engine to check existing scripts
const { getRegexScripts } = await import('../../regex/engine.js');
const existingScripts = getRegexScripts();
// Check if the HTML cleaning regex already exists
const scriptName = 'Clean HTML (From Outgoing Prompt)';
const alreadyExists = existingScripts.some(script => script.scriptName === scriptName);
if (alreadyExists) {
console.log('[RPG Companion] HTML cleaning regex already exists, skipping import');
return;
}
// Import the regex index to use the import function
const regexModule = await import('../../regex/index.js');
// Create the regex script object based on the attached file
const regexScript = {
scriptName: scriptName,
findRegex: '/\\s?<(?!\\!--)(?:\"[^\"]*\"|\'[^\']*\'|[^\'\">])*>/g',
replaceString: '',
trimStrings: [],
placement: [2], // 2 = Input (affects outgoing prompt)
disabled: false,
markdownOnly: false,
promptOnly: true,
runOnEdit: true,
substituteRegex: 0,
minDepth: null,
maxDepth: null
};
// Import using the onRegexImportObjectChange function
// We need to access it through the window object or by importing it
const { extension_settings } = await import('../../../scripts/extensions.js');
// Generate a UUID for the script
const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
regexScript.id = uuidv4();
// Add to global regex scripts
if (!Array.isArray(extension_settings.regex)) {
extension_settings.regex = [];
}
extension_settings.regex.push(regexScript);
// Import saveSettingsDebounced to save the changes
const { saveSettingsDebounced: saveExtensionSettings } = await import('../../../../script.js');
saveExtensionSettings();
console.log('[RPG Companion] β HTML cleaning regex imported successfully');
} catch (error) {
console.error('[RPG Companion] Failed to import HTML cleaning regex:', error);
// Don't throw - this is a nice-to-have feature
}
}
/**
* Main initialization function.
*/
jQuery(async () => {
try {
loadSettings();
await addExtensionSettings();
await initUI();
// Load chat-specific data for current chat
loadChatData();
// Import the HTML cleaning regex if needed
await ensureHtmlCleaningRegex();
// Register event listeners
eventSource.on(event_types.MESSAGE_SENT, onMessageSent);
eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted);
eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived);
eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, onMessageReceived);
eventSource.on(event_types.CHAT_CHANGED, onCharacterChanged);
eventSource.on(event_types.MESSAGE_SWIPED, onMessageSwiped);
// console.log('[RPG Companion] Extension loaded successfully');
} catch (error) {
console.error('[RPG Companion] Failed to initialize:', error);
throw error;
}
});