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
+15
View File
@@ -150,7 +150,10 @@ import {
onMessageSent, onMessageSent,
onMessageReceived, onMessageReceived,
onCharacterChanged, onCharacterChanged,
onChatLoaded,
onMessageDeleted,
onMessageSwiped, onMessageSwiped,
scheduleChatStateRehydration,
updatePersonaAvatar, updatePersonaAvatar,
clearExtensionPrompts, clearExtensionPrompts,
onGenerationEnded, onGenerationEnded,
@@ -229,6 +232,7 @@ async function addExtensionSettings() {
// Enabling extension - initialize UI // Enabling extension - initialize UI
await initUI(); await initUI();
loadChatData(); // Load chat data for current chat loadChatData(); // Load chat data for current chat
scheduleChatStateRehydration();
updateChatThoughts(); // Create thought bubbles if data exists updateChatThoughts(); // Create thought bubbles if data exists
injectCheckpointButton(); // Re-add checkpoint buttons injectCheckpointButton(); // Re-add checkpoint buttons
updateAllCheckpointIndicators(); // Update button states updateAllCheckpointIndicators(); // Update button states
@@ -367,6 +371,12 @@ async function initUI() {
updateChatThoughts(); updateChatThoughts();
}); });
$('#rpg-toggle-inline-thoughts').on('change', function() {
extensionSettings.thoughtsInChatStyle = $(this).prop('checked') ? 'inline' : 'corner';
saveSettings();
updateChatThoughts();
});
$('#rpg-toggle-html-prompt').on('change', function() { $('#rpg-toggle-html-prompt').on('change', function() {
extensionSettings.enableHtmlPrompt = $(this).prop('checked'); extensionSettings.enableHtmlPrompt = $(this).prop('checked');
// console.log('[RPG Companion] Toggle enableHtmlPrompt changed to:', extensionSettings.enableHtmlPrompt); // console.log('[RPG Companion] Toggle enableHtmlPrompt changed to:', extensionSettings.enableHtmlPrompt);
@@ -1047,6 +1057,7 @@ async function initUI() {
$('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests); $('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true); $('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
$('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat); $('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat);
$('#rpg-toggle-inline-thoughts').prop('checked', (extensionSettings.thoughtsInChatStyle || 'corner') === 'inline');
$('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt); $('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt);
$('#rpg-toggle-dialogue-coloring').prop('checked', extensionSettings.enableDialogueColoring); $('#rpg-toggle-dialogue-coloring').prop('checked', extensionSettings.enableDialogueColoring);
$('#rpg-toggle-deception').prop('checked', extensionSettings.enableDeceptionSystem ?? false); $('#rpg-toggle-deception').prop('checked', extensionSettings.enableDeceptionSystem ?? false);
@@ -1283,6 +1294,7 @@ jQuery(async () => {
// Load chat-specific data for current chat // Load chat-specific data for current chat
try { try {
loadChatData(); loadChatData();
scheduleChatStateRehydration();
// Initialize FAB widgets and strip widgets with any loaded data // Initialize FAB widgets and strip widgets with any loaded data
updateFabWidgets(); updateFabWidgets();
updateStripWidgets(); updateStripWidgets();
@@ -1353,6 +1365,9 @@ jQuery(async () => {
[event_types.GENERATION_STOPPED]: onGenerationEnded, [event_types.GENERATION_STOPPED]: onGenerationEnded,
[event_types.GENERATION_ENDED]: onGenerationEnded, [event_types.GENERATION_ENDED]: onGenerationEnded,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts], [event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts],
[event_types.CHAT_LOADED]: onChatLoaded,
[event_types.MESSAGE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPED]: onMessageSwiped, [event_types.MESSAGE_SWIPED]: onMessageSwiped,
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar, [event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar [event_types.SETTINGS_UPDATED]: updatePersonaAvatar
+1
View File
@@ -33,6 +33,7 @@ export const defaultSettings = {
showQuests: true, // Show quests section showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
enableHtmlPrompt: false, // Enable immersive HTML prompt injection enableHtmlPrompt: false, // Enable immersive HTML prompt injection
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) 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'; 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 * Validates extension settings structure
* @param {Object} settings - Settings object to validate * @param {Object} settings - Settings object to validate
@@ -134,6 +370,12 @@ export function loadSettings() {
settingsChanged = true; settingsChanged = true;
} }
// Normalize additive settings without introducing another schema bump.
if (!extensionSettings.thoughtsInChatStyle) {
extensionSettings.thoughtsInChatStyle = 'corner';
settingsChanged = true;
}
// Save migrated settings // Save migrated settings
if (settingsChanged) { if (settingsChanged) {
saveSettings(); saveSettings();
@@ -247,11 +489,11 @@ export function updateMessageSwipeData() {
} }
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
message.extra.rpg_companion_swipes[swipeId] = { setMessageSwipeTrackerData(message, swipeId, {
userStats: lastGeneratedData.userStats, userStats: lastGeneratedData.userStats,
infoBox: lastGeneratedData.infoBox, infoBox: lastGeneratedData.infoBox,
characterThoughts: lastGeneratedData.characterThoughts characterThoughts: lastGeneratedData.characterThoughts
}; });
// console.log('[RPG Companion] Updated message swipe data after user edit'); // console.log('[RPG Companion] Updated message swipe data after user edit');
break; break;
@@ -264,8 +506,10 @@ export function updateMessageSwipeData() {
* Automatically migrates v1 inventory to v2 format if needed. * Automatically migrates v1 inventory to v2 format if needed.
*/ */
export function loadChatData() { export function loadChatData() {
if (!chat_metadata || !chat_metadata.rpg_companion) { const savedData = chat_metadata?.rpg_companion;
// Reset to defaults if no data exists
if (!savedData) {
// Reset to defaults if no metadata exists, then try to rebuild from message swipe data below.
updateExtensionSettings({ updateExtensionSettings({
userStats: { userStats: {
health: 100, health: 100,
@@ -299,23 +543,20 @@ export function loadChatData() {
infoBox: null, infoBox: null,
characterThoughts: null characterThoughts: null
}); });
return;
} }
const savedData = chat_metadata.rpg_companion;
// Restore stats // Restore stats
if (savedData.userStats) { if (savedData?.userStats) {
extensionSettings.userStats = { ...savedData.userStats }; extensionSettings.userStats = { ...savedData.userStats };
} }
// Restore classic stats // Restore classic stats
if (savedData.classicStats) { if (savedData?.classicStats) {
extensionSettings.classicStats = { ...savedData.classicStats }; extensionSettings.classicStats = { ...savedData.classicStats };
} }
// Restore quests // Restore quests
if (savedData.quests) { if (savedData?.quests) {
extensionSettings.quests = { ...savedData.quests }; extensionSettings.quests = { ...savedData.quests };
} else { } else {
// Initialize with defaults if not present // Initialize with defaults if not present
@@ -326,7 +567,7 @@ export function loadChatData() {
} }
// Restore committed tracker data first // Restore committed tracker data first
if (savedData.committedTrackerData) { if (savedData?.committedTrackerData) {
// console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', { // console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', {
// userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null', // userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null', // infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null',
@@ -343,7 +584,7 @@ export function loadChatData() {
// Restore last generated data (for display) // Restore last generated data (for display)
// Always prefer lastGeneratedData as it contains the most recent generation (including swipes) // 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'); // console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData');
setLastGeneratedData({ ...savedData.lastGeneratedData }); setLastGeneratedData({ ...savedData.lastGeneratedData });
} else { } else {
@@ -363,6 +604,19 @@ export function loadChatData() {
// Validate inventory structure (Bug #3 fix) // Validate inventory structure (Bug #3 fix)
validateInventoryStructure(extensionSettings.userStats.inventory, 'chat'); 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); // console.log('[RPG Companion] Loaded chat data:', savedData);
} }
+2 -1
View File
@@ -10,7 +10,7 @@
* Extension settings - persisted to SillyTavern settings * Extension settings - persisted to SillyTavern settings
*/ */
export let extensionSettings = { 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, enabled: true,
autoUpdate: false, autoUpdate: false,
updateDepth: 4, // How many messages to include in the context updateDepth: 4, // How many messages to include in the context
@@ -21,6 +21,7 @@ export let extensionSettings = {
showInventory: true, // Show inventory section (v2 system) showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
narratorMode: false, // Use character card as narrator instead of fixed character references narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default) customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
customContextInstructionsPrompt: '', // Custom context instructions 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.int": "INT",
"stats.wis": "WIS", "stats.wis": "WIS",
"stats.cha": "CHA" "stats.cha": "CHA"
} }
+1 -1
View File
@@ -274,4 +274,4 @@
"stats.int": "INT", "stats.int": "INT",
"stats.wis": "VOL", "stats.wis": "VOL",
"stats.cha": "CHA" "stats.cha": "CHA"
} }
+3 -3
View File
@@ -20,7 +20,7 @@ import {
setLastActionWasSwipe, setLastActionWasSwipe,
$musicPlayerContainer $musicPlayerContainer
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData, setMessageSwipeTrackerData } from '../../core/persistence.js';
import { import {
generateSeparateUpdatePrompt generateSeparateUpdatePrompt
} from './promptBuilder.js'; } from './promptBuilder.js';
@@ -317,11 +317,11 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
} }
const currentSwipeId = lastMessage.swipe_id || 0; const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats, userStats: parsedData.userStats,
infoBox: parsedData.infoBox, infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts characterThoughts: parsedData.characterThoughts
}; });
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); // 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 { 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 // Core modules
import { import {
@@ -22,7 +22,7 @@ import {
updateCommittedTrackerData, updateCommittedTrackerData,
$musicPlayerContainer $musicPlayerContainer
} from '../../core/state.js'; } 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'; import { i18n } from '../../core/i18n.js';
// Generation & Parsing // Generation & Parsing
@@ -51,6 +51,8 @@ import { updateStripWidgets } from '../ui/desktop.js';
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js'; import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.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. * 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 * 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. * Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe. * 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; const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats, userStats: parsedData.userStats,
infoBox: parsedData.infoBox, infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts characterThoughts: parsedData.characterThoughts
}; });
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); // 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'); // 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 // Save to chat metadata
saveChatData(); saveChatData();
} }
@@ -310,6 +548,7 @@ export function onCharacterChanged() {
// Remove thought panel and icon when changing characters // Remove thought panel and icon when changing characters
$('#rpg-thought-panel').remove(); $('#rpg-thought-panel').remove();
$('#rpg-thought-icon').remove(); $('#rpg-thought-icon').remove();
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
$('#chat').off('scroll.thoughtPanel'); $('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel'); $(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel'); $(document).off('click.thoughtPanel');
@@ -328,20 +567,9 @@ export function onCharacterChanged() {
// already contains the committed state from when we last left this chat. // already contains the committed state from when we last left this chat.
// commitTrackerData() will be called naturally when new messages arrive. // commitTrackerData() will be called naturally when new messages arrive.
// Re-render with the loaded data // Re-render with the loaded data and retry once SillyTavern finishes restoring chat state.
renderUserStats(); rerenderRpgState();
renderInfoBox(); scheduleChatStateRehydration();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with loaded data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Update checkpoint indicators for the loaded chat // Update checkpoint indicators for the loaded chat
updateAllCheckpointIndicators(); updateAllCheckpointIndicators();
@@ -366,6 +594,7 @@ export function onMessageSwiped(messageIndex) {
} }
const currentSwipeId = message.swipe_id || 0; 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 // 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) // 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] !== undefined &&
message.swipes[currentSwipeId] !== null && message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0; message.swipes[currentSwipeId].length > 0;
const swipeData = getCurrentSwipeTrackerData(message);
const isPendingNewSwipe = currentSwipeId >= swipeCount;
if (!isExistingSwipe) { if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation // 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); // console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
} }
if (isPendingNewSwipe) {
lastGeneratedData.characterThoughts = null;
}
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId); // console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// IMPORTANT: onMessageSwiped is for DISPLAY only! // IMPORTANT: onMessageSwiped is for DISPLAY only!
// lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION // 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 // 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]) { if (swipeData) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Load swipe data into lastGeneratedData for display (both modes) // Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null; lastGeneratedData.userStats = swipeData.userStats || null;
@@ -417,7 +651,7 @@ export function onMessageSwiped(messageIndex) {
// Re-render the panels // Re-render the panels
renderUserStats(); renderUserStats();
renderInfoBox(); renderInfoBox();
renderThoughts(); renderThoughts({ useCommittedFallback: !isPendingNewSwipe });
renderInventory(); renderInventory();
renderQuests(); renderQuests();
renderMusicPlayer($musicPlayerContainer[0]); renderMusicPlayer($musicPlayerContainer[0]);
@@ -426,6 +660,15 @@ export function onMessageSwiped(messageIndex) {
updateChatThoughts(); updateChatThoughts();
} }
export function onMessageDeleted() {
if (!extensionSettings.enabled) {
return;
}
syncDisplayedTrackerStateFromChat();
saveChatData();
}
/** /**
* Update the persona avatar image when user switches personas * Update the persona avatar image when user switches personas
*/ */
+3 -3
View File
@@ -10,7 +10,7 @@ import {
committedTrackerData, committedTrackerData,
$infoBoxContainer $infoBoxContainer
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js'; import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js'; import { repairJSON } from '../../utils/jsonRepair.js';
@@ -989,7 +989,7 @@ export function updateInfoBoxField(field, value) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { 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'); // 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) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { 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; break;
+299 -71
View File
@@ -15,7 +15,7 @@ import {
addDebugLog addDebugLog
} from '../../core/state.js'; } from '../../core/state.js';
import { i18n } from '../../core/i18n.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 { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.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. * Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields. * Includes event listeners for editable character fields.
*/ */
export function renderThoughts({ preserveScroll = false } = {}) { export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return; return;
} }
@@ -169,7 +169,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
} }
// Don't render if no data exists (e.g., after cache clear) // 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) { if (!thoughtsData) {
$thoughtsContainer.html('<div class="rpg-inventory-empty">No character data generated yet</div>'); $thoughtsContainer.html('<div class="rpg-inventory-empty">No character data generated yet</div>');
return; return;
@@ -193,7 +193,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
const hasRelationshipEnabled = relationshipFields.length > 0; const hasRelationshipEnabled = relationshipFields.length > 0;
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) // 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 lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts));
// console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.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) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
} }
} }
break; break;
@@ -969,7 +969,7 @@ export function addNewCharacter() {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
} }
} }
break; break;
@@ -1152,7 +1152,7 @@ export function updateCharacterField(characterName, field, value) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
} }
} }
break; break;
@@ -1381,7 +1381,7 @@ export function updateCharacterField(characterName, field, value) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { 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; break;
@@ -1438,66 +1438,265 @@ function renderThoughtsSidebarOnly() {
} }
/** /**
* Updates or removes thought overlays in the chat. * Updates or removes thoughts shown in chat.
* Creates floating thought bubbles positioned near character avatars. * 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-panel').remove();
$('#rpg-thought-icon').remove(); $('#rpg-thought-icon').remove();
$('#chat').off('scroll.thoughtPanel'); $('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel'); $(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel'); $(document).off('click.thoughtPanel');
// If extension is disabled, thoughts in chat are disabled, or no thoughts, just return // Remove any existing inline thought dropdowns from previous renders
if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { $('.rpg-inline-thoughts, .rpg-inline-thought').remove();
// console.log('[RPG Companion] Thoughts in chat disabled or no data');
const canRenderThoughts = extensionSettings.enabled
&& extensionSettings.showThoughtsInChat
&& !!lastGeneratedData.characterThoughts;
if (!canRenderThoughts) {
teardownInlineThoughtsObserver();
return; return;
} }
// Parse the Present Characters data to get thoughts const thoughtsArray = parseThoughtsArray();
let thoughtsArray = []; // Array of {name, emoji, thought}
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 thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
const thoughtsLabel = thoughtsConfig?.name || 'Thoughts'; const thoughtsLabel = thoughtsConfig?.name || 'Thoughts';
// Try JSON format first
try { try {
const parsed = typeof lastGeneratedData.characterThoughts === 'string' const parsed = typeof lastGeneratedData.characterThoughts === 'string'
? JSON.parse(lastGeneratedData.characterThoughts) ? JSON.parse(lastGeneratedData.characterThoughts)
: lastGeneratedData.characterThoughts; : lastGeneratedData.characterThoughts;
// Handle both {characters: [...]} and direct array formats
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []); const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) { 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 thoughtsArray = charactersArray
.filter(char => char.thoughts && char.thoughts.content) .filter(char => char.thoughts && char.thoughts.content && !offScene.test(char.thoughts.content))
.map(char => ({ .map(char => ({
name: (char.name || '').toLowerCase(), name: (char.name || ''),
emoji: char.emoji || '👤', emoji: char.emoji || '👤',
thought: char.thoughts.content thought: char.thoughts.content
})); }));
debugLog('[RPG Thoughts Bubble] ✓ Parsed JSON format, thoughts:', thoughtsArray.length);
} }
} catch (e) { } catch (e) {
debugLog('[RPG Thoughts Bubble] Not JSON format, falling back to text parsing'); 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 && lastGeneratedData.characterThoughts) {
if (thoughtsArray.length === 0) {
const lines = lastGeneratedData.characterThoughts.split('\n'); 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 currentCharName = null;
let currentCharEmoji = null; let currentCharEmoji = null;
@@ -1513,74 +1712,103 @@ export function updateChatThoughts() {
continue; continue;
} }
// Check if this is a character name line (starts with "- ")
if (line.startsWith('- ')) { if (line.startsWith('- ')) {
const name = line.substring(2).trim(); const name = line.substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') { if (name && name.toLowerCase() !== 'unavailable') {
currentCharName = name; currentCharName = name;
currentCharEmoji = null; // Reset emoji for new character currentCharEmoji = null;
} else { } else {
currentCharName = null; currentCharName = null;
currentCharEmoji = null; currentCharEmoji = null;
} }
} } else if (line.startsWith('Details:') && currentCharName) {
// Check if this is a Details line (contains the emoji)
else if (line.startsWith('Details:') && currentCharName) {
const detailsContent = line.substring(line.indexOf(':') + 1).trim(); const detailsContent = line.substring(line.indexOf(':') + 1).trim();
const parts = detailsContent.split('|').map(p => p.trim()); const parts = detailsContent.split('|').map(p => p.trim());
// First part is the emoji
if (parts.length > 0) { if (parts.length > 0) {
currentCharEmoji = parts[0]; currentCharEmoji = parts[0];
} }
} } else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
// Check if this is a Thoughts line
else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
const thoughtContent = line.substring(thoughtsLabel.length + 1).trim(); const thoughtContent = line.substring(thoughtsLabel.length + 1).trim();
// The thought content is just the text (no emoji prefix in new format)
if (thoughtContent) { if (thoughtContent) {
thoughtsArray.push({ thoughtsArray.push({
name: currentCharName.toLowerCase(), name: currentCharName,
emoji: currentCharEmoji, emoji: currentCharEmoji,
thought: thoughtContent thought: thoughtContent
}); });
} }
} }
} }
} // End of text format parsing for thoughts bubbles }
debugLog('[RPG Thoughts] Parsed thoughts:', thoughtsArray); return thoughtsArray;
}
// If no thoughts parsed, return function escapeInlineThoughtHtml(value) {
if (thoughtsArray.length === 0) { return String(value ?? '')
// console.log('[RPG Companion] No thoughts parsed, returning'); .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; return;
} }
// console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); const thoughtsMap = {};
// console.log('[RPG Companion] Thoughts array:', thoughtsArray); for (const thoughtData of thoughtsArray) {
thoughtsMap[(thoughtData.name || '').toLowerCase()] = thoughtData;
// 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) { const $container = $('<div class="rpg-inline-thoughts"></div>');
// console.log('[RPG Companion] No target message found'); bindInlineThoughtEvents($container);
for (const [, thoughtData] of Object.entries(thoughtsMap)) {
const $dropdown = createInlineThoughtDropdown(thoughtData, openThoughts);
$container.append($dropdown);
}
if (!$container.children().length) {
return; return;
} }
// Create the thought panel with all thoughts // Mount outside .mes_text so SillyTavern's click-to-edit handlers do not
createThoughtPanel($targetMessage, thoughtsArray); // 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) ===== // ===== GLOBAL DRAGGING SETUP FOR THOUGHT ICON (MOBILE ONLY) =====
+63
View File
@@ -11677,3 +11677,66 @@ body:has(.rpg-panel[data-theme="light"]) .rpg-strip-widget {
min-width: 2.5rem !important; min-width: 2.5rem !important;
} }
} }
.rpg-inline-thoughts {
display: flex;
flex-direction: column;
gap: 0.5em;
margin: 0.2em 0 0.35em;
}
.rpg-inline-thought {
margin: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
border-left: 2px solid rgba(255, 120, 140, 0.6);
border-radius: 0.5em;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.rpg-inline-thought[open] {
background: rgba(255, 255, 255, 0.045);
}
.rpg-inline-thought-summary {
display: flex;
align-items: center;
gap: 0.45em;
padding: 0.45em 0.7em;
cursor: pointer;
list-style: none;
font-weight: 600;
color: var(--SmartThemeBodyColor);
}
.rpg-inline-thought-summary:hover {
background: rgba(255, 255, 255, 0.035);
}
.rpg-inline-thought-summary::-webkit-details-marker {
display: none;
}
.rpg-inline-thought-icon {
opacity: 0.9;
flex: 0 0 auto;
}
.rpg-inline-thought-name {
line-height: 1.2;
}
.rpg-inline-thought-content {
padding: 0 0.7em 0.65em;
}
.rpg-inline-thought-text {
margin: 0;
padding: 0.6em 0.75em;
border-radius: 0.4em;
background: rgba(0, 0, 0, 0.28);
color: var(--SmartThemeBodyColor);
font-style: italic;
line-height: 1.45;
}
+8
View File
@@ -367,6 +367,14 @@
Display character thoughts as overlay bubbles next to their messages. Display character thoughts as overlay bubbles next to their messages.
</small> </small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-inline-thoughts" />
<span>Show Thoughts Below Message Text</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;">
Switch between the default corner thought bubbles and thought cards below the message text.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-inventory" /> <input type="checkbox" id="rpg-toggle-inventory" />
<span data-i18n-key="template.settingsModal.display.showInventory">Show Inventory</span> <span data-i18n-key="template.settingsModal.display.showInventory">Show Inventory</span>