Alternate Thoughts Display (#2)

This PR adds an optional alternate display mode for RPG Companion thoughts.

When enabled, thoughts are shown as compact expandable cards directly below the relevant latest character message. When disabled, RPG Companion keeps its original corner/overlay thought bubbles, so the existing behavior is preserved unless the user explicitly switches modes.

The new display mode is built on top of RPG Companion’s existing thoughts system rather than replacing it. Thought UI now updates more reliably across new generations, swipe changes, message deletion, chat reload/re-entry, and live mode toggling, so thoughts stay attached to the correct visible message instead of lingering on stale UI. It also improves restoration of RPG Companion state after reopening a chat, making thoughts and related tracker data more consistent with the current chat view.
This commit is contained in:
Tremendoussly
2026-03-08 19:54:38 +01:00
committed by GitHub
parent 502646bb92
commit ae9e44eafb
12 changed files with 926 additions and 113 deletions
+1
View File
@@ -33,6 +33,7 @@ export const defaultSettings = {
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
+266 -12
View File
@@ -21,6 +21,242 @@ import { migrateToV3JSON } from '../utils/jsonMigration.js';
const extensionName = 'third-party/rpg-companion-sillytavern';
function hasTrackerPayload(payload) {
return !!(payload && typeof payload === 'object' && (
payload.userStats
|| payload.infoBox
|| payload.characterThoughts
));
}
function getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
if (!store) {
return null;
}
if (hasTrackerPayload(store)) {
return store;
}
const preferredKey = String(preferredSwipeId);
const preferredPayload = store[preferredKey] ?? store[preferredSwipeId];
if (hasTrackerPayload(preferredPayload)) {
return preferredPayload;
}
return null;
}
function getTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
const currentPayload = getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId);
if (currentPayload) {
return currentPayload;
}
if (!store || typeof store !== 'object') {
return null;
}
const numericKeys = Object.keys(store)
.filter(key => /^\d+$/.test(key))
.sort((a, b) => Number(b) - Number(a));
for (const key of numericKeys) {
const payload = store[key];
if (hasTrackerPayload(payload)) {
return payload;
}
}
for (const payload of Object.values(store)) {
if (hasTrackerPayload(payload)) {
return payload;
}
}
return null;
}
function ensureTrackerPayloadSlot(store, swipeId = 0) {
if (!store || typeof store !== 'object' || Array.isArray(store)) {
return null;
}
if (hasTrackerPayload(store)) {
return store;
}
if (!store[swipeId] || typeof store[swipeId] !== 'object' || Array.isArray(store[swipeId])) {
store[swipeId] = {};
}
return store[swipeId];
}
function ensureSwipeInfoEntry(message, swipeId = 0) {
if (!Array.isArray(message?.swipe_info)) {
return null;
}
if (!message.swipe_info[swipeId] || typeof message.swipe_info[swipeId] !== 'object') {
message.swipe_info[swipeId] = {
send_date: message.send_date,
gen_started: message.gen_started,
gen_finished: message.gen_finished,
extra: {}
};
}
if (!message.swipe_info[swipeId].extra || typeof message.swipe_info[swipeId].extra !== 'object') {
message.swipe_info[swipeId].extra = {};
}
return message.swipe_info[swipeId];
}
export function getCurrentMessageSwipeTrackerData(message) {
if (!message || message.is_user) {
return null;
}
const swipeId = Number(message.swipe_id ?? 0);
const preferredSources = [
message.extra?.rpg_companion_swipes,
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
];
for (const source of preferredSources) {
const payload = getCurrentTrackerPayloadFromSwipeStore(source, swipeId);
if (payload) {
return payload;
}
}
return null;
}
export function getMessageSwipeTrackerData(message) {
if (!message || message.is_user) {
return null;
}
const swipeId = Number(message.swipe_id ?? 0);
const currentPayload = getCurrentMessageSwipeTrackerData(message);
if (currentPayload) {
return currentPayload;
}
const preferredSources = [
message.extra?.rpg_companion_swipes,
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
];
for (const source of preferredSources) {
const payload = getTrackerPayloadFromSwipeStore(source, swipeId);
if (payload) {
return payload;
}
}
if (Array.isArray(message.swipe_info)) {
for (let i = message.swipe_info.length - 1; i >= 0; i--) {
const payload = getTrackerPayloadFromSwipeStore(message.swipe_info[i]?.extra?.rpg_companion_swipes, swipeId);
if (payload) {
return payload;
}
}
}
return null;
}
export function getLatestTrackerDataFromChat(chatMessages) {
if (!Array.isArray(chatMessages)) {
return null;
}
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (message?.is_user) continue;
const swipeData = getCurrentMessageSwipeTrackerData(message);
if (!swipeData) continue;
return {
userStats: swipeData.userStats || null,
infoBox: swipeData.infoBox || null,
characterThoughts: typeof swipeData.characterThoughts === 'object'
? JSON.stringify(swipeData.characterThoughts, null, 2)
: (swipeData.characterThoughts || null)
};
}
return null;
}
export function restoreLatestTrackerStateFromChat(chatMessages) {
const latestData = getLatestTrackerDataFromChat(chatMessages);
if (!latestData) {
return false;
}
setLastGeneratedData({
userStats: latestData.userStats || null,
infoBox: latestData.infoBox || null,
characterThoughts: latestData.characterThoughts || null,
html: lastGeneratedData.html || null
});
setCommittedTrackerData({
userStats: latestData.userStats || committedTrackerData.userStats || null,
infoBox: latestData.infoBox || committedTrackerData.infoBox || null,
characterThoughts: latestData.characterThoughts || committedTrackerData.characterThoughts || null
});
return true;
}
export function setMessageSwipeTrackerData(message, swipeId = 0, trackerData = {}) {
if (!message || message.is_user || !trackerData || typeof trackerData !== 'object') {
return null;
}
if (!message.extra || typeof message.extra !== 'object') {
message.extra = {};
}
if (!message.extra.rpg_companion_swipes || typeof message.extra.rpg_companion_swipes !== 'object' || Array.isArray(message.extra.rpg_companion_swipes)) {
message.extra.rpg_companion_swipes = {};
}
const extraPayload = ensureTrackerPayloadSlot(message.extra.rpg_companion_swipes, swipeId);
if (extraPayload) {
Object.assign(extraPayload, trackerData);
}
const swipeInfoEntry = ensureSwipeInfoEntry(message, swipeId);
if (swipeInfoEntry) {
if (!swipeInfoEntry.extra.rpg_companion_swipes || typeof swipeInfoEntry.extra.rpg_companion_swipes !== 'object' || Array.isArray(swipeInfoEntry.extra.rpg_companion_swipes)) {
swipeInfoEntry.extra.rpg_companion_swipes = {};
}
const swipePayload = ensureTrackerPayloadSlot(swipeInfoEntry.extra.rpg_companion_swipes, swipeId);
if (swipePayload) {
Object.assign(swipePayload, trackerData);
}
}
return extraPayload;
}
export function setMessageSwipeTrackerField(message, swipeId = 0, field, value) {
if (!field) {
return null;
}
return setMessageSwipeTrackerData(message, swipeId, { [field]: value });
}
/**
* Validates extension settings structure
* @param {Object} settings - Settings object to validate
@@ -134,6 +370,12 @@ export function loadSettings() {
settingsChanged = true;
}
// Normalize additive settings without introducing another schema bump.
if (!extensionSettings.thoughtsInChatStyle) {
extensionSettings.thoughtsInChatStyle = 'corner';
settingsChanged = true;
}
// Save migrated settings
if (settingsChanged) {
saveSettings();
@@ -247,11 +489,11 @@ export function updateMessageSwipeData() {
}
const swipeId = message.swipe_id || 0;
message.extra.rpg_companion_swipes[swipeId] = {
setMessageSwipeTrackerData(message, swipeId, {
userStats: lastGeneratedData.userStats,
infoBox: lastGeneratedData.infoBox,
characterThoughts: lastGeneratedData.characterThoughts
};
});
// console.log('[RPG Companion] Updated message swipe data after user edit');
break;
@@ -264,8 +506,10 @@ export function updateMessageSwipeData() {
* Automatically migrates v1 inventory to v2 format if needed.
*/
export function loadChatData() {
if (!chat_metadata || !chat_metadata.rpg_companion) {
// Reset to defaults if no data exists
const savedData = chat_metadata?.rpg_companion;
if (!savedData) {
// Reset to defaults if no metadata exists, then try to rebuild from message swipe data below.
updateExtensionSettings({
userStats: {
health: 100,
@@ -299,23 +543,20 @@ export function loadChatData() {
infoBox: null,
characterThoughts: null
});
return;
}
const savedData = chat_metadata.rpg_companion;
// Restore stats
if (savedData.userStats) {
if (savedData?.userStats) {
extensionSettings.userStats = { ...savedData.userStats };
}
// Restore classic stats
if (savedData.classicStats) {
if (savedData?.classicStats) {
extensionSettings.classicStats = { ...savedData.classicStats };
}
// Restore quests
if (savedData.quests) {
if (savedData?.quests) {
extensionSettings.quests = { ...savedData.quests };
} else {
// Initialize with defaults if not present
@@ -326,7 +567,7 @@ export function loadChatData() {
}
// Restore committed tracker data first
if (savedData.committedTrackerData) {
if (savedData?.committedTrackerData) {
// console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', {
// userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null',
@@ -343,7 +584,7 @@ export function loadChatData() {
// Restore last generated data (for display)
// Always prefer lastGeneratedData as it contains the most recent generation (including swipes)
if (savedData.lastGeneratedData) {
if (savedData?.lastGeneratedData) {
// console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData');
setLastGeneratedData({ ...savedData.lastGeneratedData });
} else {
@@ -363,6 +604,19 @@ export function loadChatData() {
// Validate inventory structure (Bug #3 fix)
validateInventoryStructure(extensionSettings.userStats.inventory, 'chat');
// Sync display data from the latest assistant message's stored swipe payload.
// This is more reliable than chat metadata alone on chat re-entry because the
// latest rendered swipe data may exist on the message even if the debounced
// metadata save did not flush yet.
try {
const chatContext = getContext();
const chatMessages = chatContext?.chat;
restoreLatestTrackerStateFromChat(chatMessages);
} catch (e) {
console.warn('[RPG Companion] Per-message data sync skipped:', e.message);
}
// console.log('[RPG Companion] Loaded chat data:', savedData);
}
+2 -1
View File
@@ -10,7 +10,7 @@
* Extension settings - persisted to SillyTavern settings
*/
export let extensionSettings = {
settingsVersion: 4, // Version number for settings migrations (v4 = FAB widgets enabled by default)
settingsVersion: 4, // Version number for settings migrations
enabled: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
@@ -21,6 +21,7 @@ export let extensionSettings = {
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default)
+1 -1
View File
@@ -273,4 +273,4 @@
"stats.int": "INT",
"stats.wis": "WIS",
"stats.cha": "CHA"
}
}
+1 -1
View File
@@ -274,4 +274,4 @@
"stats.int": "INT",
"stats.wis": "VOL",
"stats.cha": "CHA"
}
}
+3 -3
View File
@@ -20,7 +20,7 @@ import {
setLastActionWasSwipe,
$musicPlayerContainer
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { saveChatData, setMessageSwipeTrackerData } from '../../core/persistence.js';
import {
generateSeparateUpdatePrompt
} from './promptBuilder.js';
@@ -317,11 +317,11 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
});
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
}
+264 -21
View File
@@ -4,7 +4,7 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, saveChatDebounced } from '../../../../../../../script.js';
import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js';
// Core modules
import {
@@ -22,7 +22,7 @@ import {
updateCommittedTrackerData,
$musicPlayerContainer
} from '../../core/state.js';
import { saveChatData, loadChatData, autoSwitchPresetForEntity } from '../../core/persistence.js';
import { saveChatData, loadChatData, autoSwitchPresetForEntity, getMessageSwipeTrackerData, getCurrentMessageSwipeTrackerData, restoreLatestTrackerStateFromChat, setMessageSwipeTrackerData } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
// Generation & Parsing
@@ -51,6 +51,8 @@ import { updateStripWidgets } from '../ui/desktop.js';
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
let chatStateRehydrateRunId = 0;
/**
* 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
@@ -87,6 +89,237 @@ export function commitTrackerData() {
}
}
function getSwipeTrackerData(message) {
return getMessageSwipeTrackerData(message);
}
function getCurrentSwipeTrackerData(message) {
return getCurrentMessageSwipeTrackerData(message);
}
function hasAssistantMessageBody() {
const $messages = $('#chat .mes');
for (let i = $messages.length - 1; i >= 0; i--) {
const $message = $messages.eq(i);
if ($message.attr('is_user') === 'true') continue;
if ($message.find('.mes_text').length > 0) {
return true;
}
}
return false;
}
function hasAnyTrackerStateInChat() {
const chatMessages = getContext()?.chat || [];
for (let i = chatMessages.length - 1; i >= 0; i--) {
const swipeData = getSwipeTrackerData(chatMessages[i]);
if (swipeData?.userStats || swipeData?.infoBox || swipeData?.characterThoughts) {
return true;
}
}
return false;
}
function hasAssistantMessagesInChat() {
const chatMessages = getContext()?.chat || [];
return chatMessages.some(message => message && !message.is_user && !message.is_system);
}
function hasPotentialTrackerSourceInChat() {
const chatMessages = getContext()?.chat || [];
for (const message of chatMessages) {
if (!message || message.is_user || message.is_system) {
continue;
}
if (message.extra?.rpg_companion_swipes) {
return true;
}
if (Array.isArray(message.swipe_info) && message.swipe_info.some(info => info?.extra?.rpg_companion_swipes)) {
return true;
}
if (Array.isArray(message.swipes) && message.swipes.length > 1) {
return true;
}
}
return false;
}
function maybeRehydrateUserStatsFromDisplayData() {
const hasSavedUserStats = !!chat_metadata?.rpg_companion?.userStats;
if (!hasSavedUserStats && lastGeneratedData.userStats) {
try {
parseUserStats(lastGeneratedData.userStats);
} catch (error) {
console.warn('[RPG Companion] Failed to rebuild user stats from display data:', error);
}
}
}
function getCurrentSwipeText(message) {
const swipeId = Number(message?.swipe_id ?? 0);
if (Array.isArray(message?.swipes) && typeof message.swipes[swipeId] === 'string' && message.swipes[swipeId].trim()) {
return message.swipes[swipeId];
}
return typeof message?.mes === 'string' ? message.mes : '';
}
function repairLatestTrackerStateFromCurrentSwipeContent(chatMessages = getContext()?.chat || []) {
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (!message || message.is_user || message.is_system) {
continue;
}
const swipeId = Number(message.swipe_id ?? 0);
if (getCurrentSwipeTrackerData(message)) {
continue;
}
const currentSwipeText = getCurrentSwipeText(message);
if (!currentSwipeText) {
continue;
}
const parsedData = parseResponse(currentSwipeText);
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
if (!parsedData.userStats && !parsedData.infoBox && !parsedData.characterThoughts) {
continue;
}
setMessageSwipeTrackerData(message, swipeId, {
userStats: parsedData.userStats || null,
infoBox: parsedData.infoBox || null,
characterThoughts: parsedData.characterThoughts || null
});
return true;
}
return false;
}
function restoreOrRepairLatestTrackerState() {
const chatMessages = getContext()?.chat || [];
let restored = restoreLatestTrackerStateFromChat(chatMessages);
if (!restored) {
const repaired = repairLatestTrackerStateFromCurrentSwipeContent(chatMessages);
if (repaired) {
restored = restoreLatestTrackerStateFromChat(chatMessages);
}
}
return restored;
}
function rerenderRpgState() {
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
updateFabWidgets();
updateStripWidgets();
}
export function scheduleChatStateRehydration() {
chatStateRehydrateRunId++;
const runId = chatStateRehydrateRunId;
let attempts = 0;
const maxAttempts = 15;
const eagerRetryAttempts = 4;
const tryRestoreState = () => {
if (runId !== chatStateRehydrateRunId) {
return;
}
attempts++;
loadChatData();
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
const hasRestoredTrackerState = !!(
lastGeneratedData.userStats
|| lastGeneratedData.infoBox
|| lastGeneratedData.characterThoughts
|| committedTrackerData.userStats
|| committedTrackerData.infoBox
|| committedTrackerData.characterThoughts
);
const hasStoredTrackerState = !!chat_metadata?.rpg_companion || hasAnyTrackerStateInChat();
const hasAssistantMessages = hasAssistantMessagesInChat();
const hasPotentialTrackerSource = hasPotentialTrackerSourceInChat();
const chatBodyReady = hasAssistantMessageBody();
if (chatBodyReady) {
updateChatThoughts();
}
const shouldRetryForRestore = !hasRestoredTrackerState && (
hasStoredTrackerState
|| (hasAssistantMessages && attempts < eagerRetryAttempts)
|| (hasPotentialTrackerSource && attempts < maxAttempts)
);
const shouldRetryForDom = !chatBodyReady && hasAssistantMessages;
if ((shouldRetryForRestore || shouldRetryForDom) && attempts < maxAttempts) {
setTimeout(tryRestoreState, 200);
}
};
setTimeout(tryRestoreState, 200);
}
export function onChatLoaded() {
loadChatData();
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
scheduleChatStateRehydration();
updateAllCheckpointIndicators();
}
function syncDisplayedTrackerStateFromChat() {
const restored = restoreOrRepairLatestTrackerState();
if (!restored) {
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
rerenderRpgState();
updateChatThoughts();
}
/**
* Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe.
@@ -193,11 +426,11 @@ export async function onMessageReceived(data) {
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
});
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId);
@@ -244,6 +477,11 @@ export async function onMessageReceived(data) {
// console.log('[RPG Companion] Cleaned message, removed tracker code blocks from DOM');
// Re-insert chat thoughts after SillyTavern finishes rerendering the cleaned message DOM.
if (parsedData.characterThoughts) {
setTimeout(() => updateChatThoughts(), 100);
}
// Save to chat metadata
saveChatData();
}
@@ -310,6 +548,7 @@ export function onCharacterChanged() {
// Remove thought panel and icon when changing characters
$('#rpg-thought-panel').remove();
$('#rpg-thought-icon').remove();
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
$('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel');
@@ -328,20 +567,9 @@ export function onCharacterChanged() {
// already contains the committed state from when we last left this chat.
// commitTrackerData() will be called naturally when new messages arrive.
// Re-render with the loaded data
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with loaded data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Re-render with the loaded data and retry once SillyTavern finishes restoring chat state.
rerenderRpgState();
scheduleChatStateRehydration();
// Update checkpoint indicators for the loaded chat
updateAllCheckpointIndicators();
@@ -366,6 +594,7 @@ export function onMessageSwiped(messageIndex) {
}
const currentSwipeId = message.swipe_id || 0;
const swipeCount = Array.isArray(message.swipes) ? message.swipes.length : 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)
@@ -373,6 +602,8 @@ export function onMessageSwiped(messageIndex) {
message.swipes[currentSwipeId] !== undefined &&
message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0;
const swipeData = getCurrentSwipeTrackerData(message);
const isPendingNewSwipe = currentSwipeId >= swipeCount;
if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation
@@ -384,13 +615,16 @@ export function onMessageSwiped(messageIndex) {
// console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
}
if (isPendingNewSwipe) {
lastGeneratedData.characterThoughts = null;
}
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// IMPORTANT: onMessageSwiped is for DISPLAY only!
// lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION
// It's safe to load swipe data into lastGeneratedData - it won't be committed due to !lastActionWasSwipe check
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
if (swipeData) {
// Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null;
@@ -417,7 +651,7 @@ export function onMessageSwiped(messageIndex) {
// Re-render the panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderThoughts({ useCommittedFallback: !isPendingNewSwipe });
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
@@ -426,6 +660,15 @@ export function onMessageSwiped(messageIndex) {
updateChatThoughts();
}
export function onMessageDeleted() {
if (!extensionSettings.enabled) {
return;
}
syncDisplayedTrackerStateFromChat();
saveChatData();
}
/**
* Update the persona avatar image when user switches personas
*/
+3 -3
View File
@@ -10,7 +10,7 @@ import {
committedTrackerData,
$infoBoxContainer
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { saveChatData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js';
@@ -989,7 +989,7 @@ export function updateInfoBoxField(field, value) {
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');
setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
// console.log('[RPG Companion] Updated infoBox in message swipe data');
}
}
@@ -1074,7 +1074,7 @@ function updateRecentEvent(field, value) {
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');
setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
}
}
break;
+299 -71
View File
@@ -15,7 +15,7 @@ import {
addDebugLog
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { saveChatData, saveSettings } from '../../core/persistence.js';
import { saveChatData, saveSettings, getCurrentMessageSwipeTrackerData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
@@ -154,7 +154,7 @@ function namesMatch(cardName, aiName) {
* Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields.
*/
export function renderThoughts({ preserveScroll = false } = {}) {
export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return;
}
@@ -169,7 +169,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
}
// Don't render if no data exists (e.g., after cache clear)
const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts;
const thoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null);
if (!thoughtsData) {
$thoughtsContainer.html('<div class="rpg-inventory-empty">No character data generated yet</div>');
return;
@@ -193,7 +193,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
const hasRelationshipEnabled = relationshipFields.length > 0;
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || '';
const characterThoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || '';
// console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts));
// console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts));
@@ -839,7 +839,7 @@ export function removeCharacter(characterName) {
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 = lastGeneratedData.characterThoughts;
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
}
}
break;
@@ -969,7 +969,7 @@ export function addNewCharacter() {
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 = lastGeneratedData.characterThoughts;
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
}
}
break;
@@ -1152,7 +1152,7 @@ export function updateCharacterField(characterName, field, value) {
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 = lastGeneratedData.characterThoughts;
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
}
}
break;
@@ -1381,7 +1381,7 @@ export function updateCharacterField(characterName, field, value) {
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 = lines.join('\n');
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lines.join('\n'));
}
}
break;
@@ -1438,66 +1438,265 @@ function renderThoughtsSidebarOnly() {
}
/**
* Updates or removes thought overlays in the chat.
* Creates floating thought bubbles positioned near character avatars.
* Updates or removes thoughts shown in chat.
* Renders either the original corner bubbles or inline dropdown cards.
*/
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
let inlineThoughtsObserver = null;
let inlineThoughtsRefreshTimeout = null;
let isRefreshingInlineThoughts = false;
export function updateChatThoughts(attempt = 0) {
const thoughtsStyle = extensionSettings.thoughtsInChatStyle || 'corner';
const openInlineThoughts = getOpenInlineThoughts();
// Remove old floating thought panel/icon (legacy cleanup)
$('#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');
// Remove any existing inline thought dropdowns from previous renders
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
const canRenderThoughts = extensionSettings.enabled
&& extensionSettings.showThoughtsInChat
&& !!lastGeneratedData.characterThoughts;
if (!canRenderThoughts) {
teardownInlineThoughtsObserver();
return;
}
// Parse the Present Characters data to get thoughts
let thoughtsArray = []; // Array of {name, emoji, thought}
const thoughtsArray = parseThoughtsArray();
if (thoughtsArray.length === 0) {
teardownInlineThoughtsObserver();
return;
}
const targetInfo = findThoughtTargetMessage();
let $targetMessage = targetInfo.$message;
if ((!$targetMessage || !$targetMessage.length || !$targetMessage.find('.mes_text').length) && attempt < 10) {
setTimeout(() => updateChatThoughts(attempt + 1), 120);
return;
}
if (!$targetMessage || !$targetMessage.length) {
teardownInlineThoughtsObserver();
return;
}
if (thoughtsStyle === 'inline') {
insertInlineThoughts($targetMessage, thoughtsArray, openInlineThoughts);
ensureInlineThoughtsObserver();
} else {
teardownInlineThoughtsObserver();
createThoughtPanel($targetMessage, thoughtsArray);
}
}
function findThoughtTargetMessage() {
const context = getContext();
const chat = context?.chat || [];
const currentThoughts = normalizeThoughtPayload(lastGeneratedData.characterThoughts);
let fallbackIndex = -1;
// Match the currently displayed thoughts against stored swipe payloads so the
// UI stays attached to the visible assistant reply after swipes, deletes, and reloads.
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (message?.is_user) continue;
if (fallbackIndex === -1) {
fallbackIndex = i;
}
const messageThoughts = getMessageThoughtPayload(message);
if (!currentThoughts || !messageThoughts) continue;
if (messageThoughts === currentThoughts) {
const $message = $(`#chat .mes[mesid="${i}"]`);
return { index: i, $message };
}
}
if (fallbackIndex !== -1) {
return {
index: fallbackIndex,
$message: $(`#chat .mes[mesid="${fallbackIndex}"]`)
};
}
return { index: -1, $message: $() };
}
function getMessageThoughtPayload(message) {
if (!message || message.is_user) {
return null;
}
const swipeData = getCurrentMessageSwipeTrackerData(message);
return normalizeThoughtPayload(swipeData?.characterThoughts ?? null);
}
function normalizeThoughtPayload(payload) {
if (!payload) {
return null;
}
if (typeof payload === 'object') {
return stableStringify(payload);
}
if (typeof payload !== 'string') {
return String(payload);
}
const trimmed = payload.trim();
if (!trimmed) {
return null;
}
try {
return stableStringify(JSON.parse(trimmed));
} catch {
return trimmed.replace(/\r\n/g, '\n');
}
}
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(item => stableStringify(item)).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
}
return JSON.stringify(value);
}
function getOpenInlineThoughts() {
const openThoughts = new Set();
$('.rpg-inline-thought[open]').each(function () {
const characterName = ($(this).attr('data-character') || '').trim();
if (characterName) {
openThoughts.add(characterName);
}
});
return openThoughts;
}
function ensureInlineThoughtsObserver() {
if (inlineThoughtsObserver) {
return;
}
const chatElement = document.getElementById('chat');
if (!chatElement) {
return;
}
inlineThoughtsObserver = new MutationObserver((mutations) => {
if (isRefreshingInlineThoughts) {
return;
}
// SillyTavern rerenders message DOM after swipes, deletes, and message cleanup.
// Watch for those chat-level mutations and reattach inline thoughts once the
// target message body exists again, while ignoring our own refresh churn.
const shouldRefresh = mutations.some((mutation) => {
if (mutation.type !== 'childList') {
return false;
}
const touchedThoughtNode = [...mutation.addedNodes, ...mutation.removedNodes].some((node) => {
if (!(node instanceof HTMLElement)) {
return false;
}
return node.classList?.contains('mes')
|| node.classList?.contains('mes_text')
|| node.querySelector?.('.mes, .mes_text');
});
return touchedThoughtNode;
});
if (!shouldRefresh) {
return;
}
clearTimeout(inlineThoughtsRefreshTimeout);
inlineThoughtsRefreshTimeout = setTimeout(() => {
if (!extensionSettings.enabled
|| !extensionSettings.showThoughtsInChat
|| (extensionSettings.thoughtsInChatStyle || 'corner') !== 'inline') {
teardownInlineThoughtsObserver();
return;
}
isRefreshingInlineThoughts = true;
try {
updateChatThoughts();
} finally {
isRefreshingInlineThoughts = false;
}
}, 50);
});
inlineThoughtsObserver.observe(chatElement, {
childList: true,
subtree: true
});
}
function teardownInlineThoughtsObserver() {
if (inlineThoughtsRefreshTimeout) {
clearTimeout(inlineThoughtsRefreshTimeout);
inlineThoughtsRefreshTimeout = null;
}
if (inlineThoughtsObserver) {
inlineThoughtsObserver.disconnect();
inlineThoughtsObserver = null;
}
}
function parseThoughtsArray() {
let thoughtsArray = [];
const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
const thoughtsLabel = thoughtsConfig?.name || 'Thoughts';
// Try JSON format first
try {
const parsed = typeof lastGeneratedData.characterThoughts === 'string'
? JSON.parse(lastGeneratedData.characterThoughts)
: lastGeneratedData.characterThoughts;
// Handle both {characters: [...]} and direct array formats
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) {
// Extract thoughts from JSON character objects
const offScene = /\b(not\s+(currently\s+)?(in|at|present|in\s+the)\s+(the\s+)?(scene|area|room|location|vicinity))\b|\b(off[\s-]?scene)\b|\b(not\s+present)\b|\b(absent)\b|\b(away\s+from\s+(the\s+)?scene)\b/i;
thoughtsArray = charactersArray
.filter(char => char.thoughts && char.thoughts.content)
.filter(char => char.thoughts && char.thoughts.content && !offScene.test(char.thoughts.content))
.map(char => ({
name: (char.name || '').toLowerCase(),
name: (char.name || ''),
emoji: char.emoji || '👤',
thought: char.thoughts.content
}));
debugLog('[RPG Thoughts Bubble] ✓ Parsed JSON format, thoughts:', thoughtsArray.length);
}
} catch (e) {
debugLog('[RPG Thoughts Bubble] Not JSON format, falling back to text parsing');
}
// If JSON parsing failed or returned empty, try text format
if (thoughtsArray.length === 0) {
if (thoughtsArray.length === 0 && lastGeneratedData.characterThoughts) {
const lines = lastGeneratedData.characterThoughts.split('\n');
// console.log('[RPG Companion] Parsing thoughts from lines:', lines);
// Parse new format to build character map and thoughts
let currentCharName = null;
let currentCharEmoji = null;
@@ -1513,74 +1712,103 @@ export function updateChatThoughts() {
continue;
}
// Check if this is a character name line (starts with "- ")
if (line.startsWith('- ')) {
const name = line.substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') {
currentCharName = name;
currentCharEmoji = null; // Reset emoji for new character
currentCharEmoji = null;
} else {
currentCharName = null;
currentCharEmoji = null;
}
}
// Check if this is a Details line (contains the emoji)
else if (line.startsWith('Details:') && currentCharName) {
} else if (line.startsWith('Details:') && currentCharName) {
const detailsContent = line.substring(line.indexOf(':') + 1).trim();
const parts = detailsContent.split('|').map(p => p.trim());
// First part is the emoji
if (parts.length > 0) {
currentCharEmoji = parts[0];
}
}
// Check if this is a Thoughts line
else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
} else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
const thoughtContent = line.substring(thoughtsLabel.length + 1).trim();
// The thought content is just the text (no emoji prefix in new format)
if (thoughtContent) {
thoughtsArray.push({
name: currentCharName.toLowerCase(),
name: currentCharName,
emoji: currentCharEmoji,
thought: thoughtContent
});
}
}
}
} // End of text format parsing for thoughts bubbles
}
debugLog('[RPG Thoughts] Parsed thoughts:', thoughtsArray);
return thoughtsArray;
}
// If no thoughts parsed, return
if (thoughtsArray.length === 0) {
// console.log('[RPG Companion] No thoughts parsed, returning');
function escapeInlineThoughtHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function insertInlineThoughts($message, thoughtsArray, openThoughts = new Set()) {
const $mesText = $message.find('.mes_text');
if (!$mesText.length) {
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;
}
const thoughtsMap = {};
for (const thoughtData of thoughtsArray) {
thoughtsMap[(thoughtData.name || '').toLowerCase()] = thoughtData;
}
if (!$targetMessage) {
// console.log('[RPG Companion] No target message found');
const $container = $('<div class="rpg-inline-thoughts"></div>');
bindInlineThoughtEvents($container);
for (const [, thoughtData] of Object.entries(thoughtsMap)) {
const $dropdown = createInlineThoughtDropdown(thoughtData, openThoughts);
$container.append($dropdown);
}
if (!$container.children().length) {
return;
}
// Create the thought panel with all thoughts
createThoughtPanel($targetMessage, thoughtsArray);
// Mount outside .mes_text so SillyTavern's click-to-edit handlers do not
// intercept summary clicks before the details element can toggle.
const $mediaWrapper = $message.find('.mes_media_wrapper').first();
if ($mediaWrapper.length) {
$container.insertBefore($mediaWrapper);
} else {
$container.insertAfter($mesText.last());
}
}
function bindInlineThoughtEvents($container) {
$container.on('click mousedown touchstart', '.rpg-inline-thought, .rpg-inline-thought-summary, .rpg-inline-thought-content', function (e) {
e.stopPropagation();
});
}
function createInlineThoughtDropdown(thoughtData, openThoughts = new Set()) {
const characterName = thoughtData.name || '';
const characterEmoji = thoughtData.emoji || '👤';
const thoughtText = thoughtData.thought || '';
const normalizedCharacterName = characterName.toLowerCase();
const openAttribute = openThoughts.has(normalizedCharacterName) ? ' open' : '';
return $(`
<details class="rpg-inline-thought" data-character="${escapeInlineThoughtHtml(normalizedCharacterName)}"${openAttribute}>
<summary class="rpg-inline-thought-summary">
<span class="rpg-inline-thought-icon">${escapeInlineThoughtHtml(characterEmoji)}</span>
<span class="rpg-inline-thought-name">${escapeInlineThoughtHtml(characterName)}'s thoughts</span>
</summary>
<div class="rpg-inline-thought-content">
<div class="rpg-inline-thought-text">${escapeInlineThoughtHtml(thoughtText)}</div>
</div>
</details>
`);
}
// ===== GLOBAL DRAGGING SETUP FOR THOUGHT ICON (MOBILE ONLY) =====