diff --git a/index.js b/index.js index 7131b9e..7b39b84 100644 --- a/index.js +++ b/index.js @@ -134,6 +134,20 @@ import { removeDesktopTabs, updateStripWidgets } from './src/systems/ui/desktop.js'; +import { + removeAlternatePresentCharactersPanel, + renderAlternatePresentCharacters +} from './src/systems/ui/alternatePresentCharacters.js'; +import { + initThoughtBasedExpressions, + queueThoughtBasedExpressionsUpdate, + onThoughtBasedExpressionsSettingChanged, + onAlternatePresentCharactersVisibilityChanged, + onHideDefaultExpressionDisplaySettingChanged, + clearThoughtBasedExpressionsCache, + onThoughtBasedExpressionsChatChanged, + setThoughtBasedExpressionsRefreshHandler +} from './src/systems/integration/thoughtBasedExpressions.js'; // Feature modules import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js'; @@ -150,8 +164,10 @@ import { onMessageSent, onMessageReceived, onCharacterChanged, - onMessageSwiped, + onChatLoaded, onMessageDeleted, + onMessageSwiped, + scheduleChatStateRehydration, updatePersonaAvatar, clearExtensionPrompts, onGenerationEnded, @@ -161,6 +177,10 @@ import { // Old state variable declarations removed - now imported from core modules // (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js) +setThoughtBasedExpressionsRefreshHandler(() => { + renderAlternatePresentCharacters({ useCommittedFallback: true }); +}); + // Utility functions removed - now imported from src/utils/avatars.js // (getSafeThumbnailUrl) @@ -217,6 +237,7 @@ async function addExtensionSettings() { clearExtensionPrompts(); updateChatThoughts(); // Remove thought bubbles cleanupCheckpointUI(); // Remove checkpoint buttons and indicators + clearThoughtBasedExpressionsCache(); // Disable dynamic weather effects toggleDynamicWeather(false); @@ -226,10 +247,13 @@ async function addExtensionSettings() { $('#rpg-mobile-toggle').remove(); $('#rpg-collapse-toggle').remove(); $('#rpg-plot-buttons').remove(); // Remove plot buttons + removeAlternatePresentCharactersPanel(); } else if (extensionSettings.enabled && !wasEnabled) { // Enabling extension - initialize UI await initUI(); loadChatData(); // Load chat data for current chat + scheduleChatStateRehydration(); + initThoughtBasedExpressions(); updateChatThoughts(); // Create thought bubbles if data exists injectCheckpointButton(); // Re-add checkpoint buttons updateAllCheckpointIndicators(); // Update button states @@ -336,6 +360,26 @@ async function initUI() { extensionSettings.showCharacterThoughts = $(this).prop('checked'); saveSettings(); updateSectionVisibility(); + renderThoughts(); + }); + + $('#rpg-toggle-alt-present-characters').on('change', function() { + extensionSettings.showAlternatePresentCharactersPanel = $(this).prop('checked'); + saveSettings(); + renderThoughts(); + onAlternatePresentCharactersVisibilityChanged(); + }); + + $('#rpg-toggle-thought-based-expressions').on('change', function() { + extensionSettings.enableThoughtBasedExpressions = $(this).prop('checked'); + saveSettings(); + onThoughtBasedExpressionsSettingChanged(extensionSettings.enableThoughtBasedExpressions); + }); + + $('#rpg-toggle-hide-default-expressions').on('change', function() { + extensionSettings.hideDefaultExpressionDisplay = $(this).prop('checked'); + saveSettings(); + onHideDefaultExpressionDisplaySettingChanged(extensionSettings.hideDefaultExpressionDisplay); }); $('#rpg-toggle-inventory').on('change', function() { @@ -368,6 +412,12 @@ async function initUI() { updateChatThoughts(); }); + $('#rpg-toggle-inline-thoughts').on('change', function() { + extensionSettings.thoughtsInChatStyle = $(this).prop('checked') ? 'inline' : 'corner'; + saveSettings(); + updateChatThoughts(); + }); + $('#rpg-toggle-html-prompt').on('change', function() { extensionSettings.enableHtmlPrompt = $(this).prop('checked'); // console.log('[RPG Companion] Toggle enableHtmlPrompt changed to:', extensionSettings.enableHtmlPrompt); @@ -1066,10 +1116,14 @@ async function initUI() { $('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats); $('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox); $('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts); + $('#rpg-toggle-alt-present-characters').prop('checked', extensionSettings.showAlternatePresentCharactersPanel ?? false); + $('#rpg-toggle-thought-based-expressions').prop('checked', extensionSettings.enableThoughtBasedExpressions === true); + $('#rpg-toggle-hide-default-expressions').prop('checked', extensionSettings.hideDefaultExpressionDisplay === true); $('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory); $('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests); $('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true); $('#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-dialogue-coloring').prop('checked', extensionSettings.enableDialogueColoring); $('#rpg-toggle-deception').prop('checked', extensionSettings.enableDeceptionSystem ?? false); @@ -1306,6 +1360,8 @@ jQuery(async () => { // Load chat-specific data for current chat try { loadChatData(); + scheduleChatStateRehydration(); + initThoughtBasedExpressions(); // Initialize FAB widgets and strip widgets with any loaded data updateFabWidgets(); updateStripWidgets(); @@ -1376,11 +1432,69 @@ jQuery(async () => { [event_types.GENERATION_STOPPED]: onGenerationEnded, [event_types.GENERATION_ENDED]: onGenerationEnded, [event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts], - [event_types.MESSAGE_SWIPED]: onMessageSwiped, + [event_types.CHAT_LOADED]: onChatLoaded, [event_types.MESSAGE_DELETED]: onMessageDeleted, + [event_types.MESSAGE_SWIPE_DELETED]: onMessageDeleted, + [event_types.MESSAGE_SWIPED]: onMessageSwiped, [event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar, [event_types.SETTINGS_UPDATED]: updatePersonaAvatar }); + + eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, (messageId) => { + if (!extensionSettings.enabled) { + return; + } + + const renderedMessage = chat[messageId]; + if (renderedMessage && !renderedMessage.is_user && !renderedMessage.is_system) { + queueThoughtBasedExpressionsUpdate(); + } + }); + + eventSource.on(event_types.MESSAGE_UPDATED, (messageId) => { + if (!extensionSettings.enabled) { + return; + } + + const updatedMessage = chat[messageId]; + if (updatedMessage && !updatedMessage.is_user && !updatedMessage.is_system) { + queueThoughtBasedExpressionsUpdate(); + } + }); + + eventSource.on(event_types.MESSAGE_SWIPED, (messageIndex) => { + if (!extensionSettings.enabled) { + return; + } + + const swipedMessage = chat[messageIndex]; + if (swipedMessage && !swipedMessage.is_user && !swipedMessage.is_system) { + queueThoughtBasedExpressionsUpdate({ immediate: true }); + } + }); + + eventSource.on(event_types.CHAT_CHANGED, () => { + clearThoughtBasedExpressionsCache(); + setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0); + }); + + eventSource.on(event_types.MESSAGE_DELETED, () => { + if (!extensionSettings.enabled) { + return; + } + + clearThoughtBasedExpressionsCache(); + setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0); + }); + + eventSource.on(event_types.MESSAGE_SWIPE_DELETED, () => { + if (!extensionSettings.enabled) { + return; + } + + clearThoughtBasedExpressionsCache(); + setTimeout(() => onThoughtBasedExpressionsChatChanged(), 0); + }); } catch (error) { console.error('[RPG Companion] Event registration failed:', error); throw error; // This is critical - can't continue without events diff --git a/src/core/config.js b/src/core/config.js index 7c7750e..f7f2794 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -29,10 +29,14 @@ export const defaultSettings = { showUserStats: true, showInfoBox: true, showCharacterThoughts: true, + showAlternatePresentCharactersPanel: false, + enableThoughtBasedExpressions: false, + hideDefaultExpressionDisplay: false, showInventory: true, // Show inventory section (v2 system) 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) diff --git a/src/core/persistence.js b/src/core/persistence.js index 5cf7c96..e92fd79 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -9,10 +9,13 @@ import { extensionSettings, lastGeneratedData, committedTrackerData, + thoughtBasedExpressionPortraits, setExtensionSettings, updateExtensionSettings, setLastGeneratedData, setCommittedTrackerData, + setThoughtBasedExpressionPortraits, + clearThoughtBasedExpressionPortraits, FEATURE_FLAGS } from './state.js'; import { migrateInventory } from '../utils/migration.js'; @@ -21,6 +24,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 +373,22 @@ export function loadSettings() { settingsChanged = true; } + // Normalize additive settings without introducing another schema bump. + if (!extensionSettings.thoughtsInChatStyle) { + extensionSettings.thoughtsInChatStyle = 'corner'; + settingsChanged = true; + } + + if (extensionSettings.showAlternatePresentCharactersPanel === undefined) { + extensionSettings.showAlternatePresentCharactersPanel = false; + settingsChanged = true; + } + + if (extensionSettings.hideDefaultExpressionDisplay === undefined) { + extensionSettings.hideDefaultExpressionDisplay = false; + settingsChanged = true; + } + // Save migrated settings if (settingsChanged) { saveSettings(); @@ -218,6 +473,7 @@ export function saveChatData() { quests: extensionSettings.quests, lastGeneratedData: lastGeneratedData, committedTrackerData: committedTrackerData, + thoughtBasedExpressionPortraits: thoughtBasedExpressionPortraits, timestamp: Date.now() }; @@ -257,7 +513,7 @@ export function updateMessageSwipeData() { // Find the last assistant message for (let i = chat.length - 1; i >= 0; i--) { const message = chat[i]; - if (!message.is_user) { + if (!message.is_user && !message.is_system) { // Found last assistant message - update its swipe data if (!message.extra) { message.extra = {}; @@ -267,15 +523,11 @@ export function updateMessageSwipeData() { } const swipeId = message.swipe_id || 0; - const swipeEntry = { + setMessageSwipeTrackerData(message, swipeId, { userStats: lastGeneratedData.userStats, infoBox: lastGeneratedData.infoBox, characterThoughts: lastGeneratedData.characterThoughts - }; - message.extra.rpg_companion_swipes[swipeId] = swipeEntry; - - // Mirror to swipe_info so data survives page reloads regardless of active swipe - mirrorToSwipeInfo(message, swipeId, swipeEntry); + }); // console.log('[RPG Companion] Updated message swipe data after user edit'); break; @@ -405,8 +657,10 @@ export function inheritSwipeDataFromPriorMessage(message, messageIndex) { * 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, @@ -440,23 +694,21 @@ export function loadChatData() { infoBox: null, characterThoughts: null }); - return; + clearThoughtBasedExpressionPortraits(); } - 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 @@ -467,7 +719,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', @@ -484,13 +736,19 @@ 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 { // console.log('[RPG Companion] ⚠️ No lastGeneratedData found in save'); } + if (savedData?.thoughtBasedExpressionPortraits && typeof savedData.thoughtBasedExpressionPortraits === 'object') { + setThoughtBasedExpressionPortraits(savedData.thoughtBasedExpressionPortraits); + } else { + clearThoughtBasedExpressionPortraits(); + } + // Migrate inventory in chat data if feature flag enabled if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) { const migrationResult = migrateInventory(extensionSettings.userStats.inventory); @@ -504,6 +762,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); } diff --git a/src/core/state.js b/src/core/state.js index 71e4e66..6ebff56 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -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 @@ -18,9 +18,13 @@ export let extensionSettings = { showUserStats: true, showInfoBox: true, showCharacterThoughts: true, + showAlternatePresentCharactersPanel: false, + enableThoughtBasedExpressions: false, + hideDefaultExpressionDisplay: false, 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) @@ -359,6 +363,24 @@ export function clearSessionAvatarPrompts() { sessionAvatarPrompts = {}; } +/** + * Per-chat storage for thought-based Character Expressions portraits. + * Maps normalized character names to the current below-chat portrait URL. + */ +export let thoughtBasedExpressionPortraits = {}; + +export function setThoughtBasedExpressionPortraits(portraits) { + thoughtBasedExpressionPortraits = portraits && typeof portraits === 'object' ? { ...portraits } : {}; +} + +export function getThoughtBasedExpressionPortrait(characterName) { + return thoughtBasedExpressionPortraits[characterName] || null; +} + +export function clearThoughtBasedExpressionPortraits() { + thoughtBasedExpressionPortraits = {}; +} + /** * Tracks whether the last action was a swipe (for separate mode) * Used to determine whether to commit lastGeneratedData to committedTrackerData diff --git a/src/i18n/en.json b/src/i18n/en.json index 1c944da..ec15696 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -36,6 +36,12 @@ "template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.", "template.settingsModal.display.showPresentCharacters": "Show Present Characters", "template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.", + "template.settingsModal.display.showBelowChatPresentCharacters": "Show Below-Chat Present Characters", + "template.settingsModal.display.showBelowChatPresentCharactersNote": "Display a compact Present Characters panel below the chat.", + "template.settingsModal.display.thoughtBasedExpressions": "Thought-Based Expressions", + "template.settingsModal.display.thoughtBasedExpressionsNote": "Use SillyTavern Character Expressions to classify each present character's thoughts for the below-chat panel. May increase token usage depending on the selected Classifier API.", + "template.settingsModal.display.hideDefaultExpressionDisplay": "Hide Default Expression Display", + "template.settingsModal.display.hideDefaultExpressionDisplayNote": "Hide SillyTavern's built-in Character Expressions display.", "template.settingsModal.display.narratorMode": "Narrator Mode", "template.settingsModal.display.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.", "template.settingsModal.display.showInventory": "Show Inventory", @@ -46,6 +52,8 @@ "template.settingsModal.display.showLockIconsNote": "Display lock/unlock icons on tracker items to prevent AI from modifying them.", "template.settingsModal.display.showThoughtsInChat": "Show Thoughts", "template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages.", + "template.settingsModal.display.showInlineThoughts": "Show Thoughts Below Message Text", + "template.settingsModal.display.showInlineThoughtsNote": "Switch between the default corner thought bubbles and thought cards below the message text.", "template.settingsModal.display.alwaysShowThoughtBubble": "Always Show Thought Bubble", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first", "template.settingsModal.display.enableAnimations": "Enable Animations", @@ -456,4 +464,4 @@ "userStats.clickToEdit": "Click to edit", "quests.main.addQuestTitle": "Add main quests", "quests.optional.addQuestTitle": "Add optional quest" -} \ No newline at end of file +} diff --git a/src/i18n/fr.json b/src/i18n/fr.json index c378a62..19a6f1c 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -36,6 +36,12 @@ "template.settingsModal.display.showInfoBoxNote": "Afficher le lieu, l'heure, la météo et les événements récents.", "template.settingsModal.display.showPresentCharacters": "Afficher Personnages Présents", "template.settingsModal.display.showPresentCharactersNote": "Afficher les portraits des personnages avec leurs pensées actuelles et leur statut.", + "template.settingsModal.display.showBelowChatPresentCharacters": "Afficher les personnages sous le chat", + "template.settingsModal.display.showBelowChatPresentCharactersNote": "Afficher un panneau compact des personnages présents sous le chat.", + "template.settingsModal.display.thoughtBasedExpressions": "Expressions basées sur les pensées", + "template.settingsModal.display.thoughtBasedExpressionsNote": "Utiliser Character Expressions de SillyTavern pour classifier les pensées de chaque personnage présent dans le panneau sous le chat. L'utilisation de tokens peut augmenter selon l'API de classification sélectionnée.", + "template.settingsModal.display.hideDefaultExpressionDisplay": "Masquer l'affichage d'expressions par défaut", + "template.settingsModal.display.hideDefaultExpressionDisplayNote": "Masquer l'affichage intégré des expressions de personnage de SillyTavern.", "template.settingsModal.display.narratorMode": "Mode Narrateur", "template.settingsModal.display.narratorModeNote": "Utiliser la carte de personnage comme narrateur. Déduire les personnages du contexte au lieu d'utiliser des références de personnages fixes.", "template.settingsModal.display.showInventory": "Afficher Inventaire", @@ -46,6 +52,8 @@ "template.settingsModal.display.showLockIconsNote": "Afficher les icônes de verrouillage/déverrouillage sur les éléments de suivi pour empêcher l'IA de les modifier.", "template.settingsModal.display.showThoughtsInChat": "Afficher Pensées", "template.settingsModal.display.showThoughtsInChatNote": "Afficher les pensées des personnages sous forme de bulles superposées à côté de leurs messages.", + "template.settingsModal.display.showInlineThoughts": "Afficher les pensées sous le texte du message", + "template.settingsModal.display.showInlineThoughtsNote": "Basculer entre les bulles de pensée dans le coin par défaut et des cartes de pensée sous le texte du message.", "template.settingsModal.display.alwaysShowThoughtBubble": "Toujours Afficher Bulle Pensée", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "Développer automatiquement la bulle de pensée sans cliquer sur l'icône d'abord", "template.settingsModal.display.enableAnimations": "Activer Animations", @@ -275,4 +283,4 @@ "stats.int": "INT", "stats.wis": "VOL", "stats.cha": "CHA" -} \ No newline at end of file +} diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 6c6f64e..883f47a 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -35,6 +35,12 @@ "template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.", "template.settingsModal.display.showPresentCharacters": "Показывать персонажей", "template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.", + "template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом", + "template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.", + "template.settingsModal.display.thoughtBasedExpressions": "Выражения на основе мыслей", + "template.settingsModal.display.thoughtBasedExpressionsNote": "Использовать Character Expressions в SillyTavern для классификации мыслей каждого присутствующего персонажа в панели под чатом. Расход токенов может увеличиться в зависимости от выбранного API классификации.", + "template.settingsModal.display.hideDefaultExpressionDisplay": "Скрыть отображение выражений по умолчанию", + "template.settingsModal.display.hideDefaultExpressionDisplayNote": "Скрыть встроенное отображение выражений персонажей SillyTavern.", "template.settingsModal.display.narratorMode": "Режим расказчика", "template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.", "template.settingsModal.display.showInventory": "Показывать инвентарь", @@ -45,6 +51,8 @@ "template.settingsModal.display.showLockIconsNote": "Отображать значки блокировки/разблокировки на элементах трекера, чтобы предотвратить их изменение ИИ.", "template.settingsModal.display.showThoughtsInChat": "Показывать мысли", "template.settingsModal.display.showThoughtsInChatNote": "Отображать мысли персонажей в виде всплывающих пузырьков рядом с их сообщениями.", + "template.settingsModal.display.showInlineThoughts": "Показывать мысли под текстом сообщения", + "template.settingsModal.display.showInlineThoughtsNote": "Переключает между стандартными угловыми пузырями мыслей и карточками мыслей под текстом сообщения.", "template.settingsModal.display.alwaysShowThoughtBubble": "Всегда показывать пузырь мыслей", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "Автоматически раскрывать пузырь мыслей без предварительного нажатия на значок", "template.settingsModal.display.enableAnimations": "Включить анимации", diff --git a/src/i18n/zh-cn.json b/src/i18n/zh-cn.json index 8c7e541..af7e3da 100644 --- a/src/i18n/zh-cn.json +++ b/src/i18n/zh-cn.json @@ -36,6 +36,12 @@ "template.settingsModal.display.showInfoBoxNote": "显示位置、时间、天气和最近事件。", "template.settingsModal.display.showPresentCharacters": "显示在场角色", "template.settingsModal.display.showPresentCharactersNote": "显示角色肖像及其当前想法和状态。", + "template.settingsModal.display.showBelowChatPresentCharacters": "显示聊天下方的在场角色", + "template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方显示紧凑的在场角色面板。", + "template.settingsModal.display.thoughtBasedExpressions": "基于想法的表情", + "template.settingsModal.display.thoughtBasedExpressionsNote": "使用 SillyTavern Character Expressions 对聊天下方面板中每个在场角色的想法进行分类。Token 用量可能会因所选的分类 API 而增加。", + "template.settingsModal.display.hideDefaultExpressionDisplay": "隐藏默认表情显示", + "template.settingsModal.display.hideDefaultExpressionDisplayNote": "隐藏 SillyTavern 内置的角色表情显示。", "template.settingsModal.display.narratorMode": "旁白模式", "template.settingsModal.display.narratorModeNote": "使用角色卡作为旁白。根据上下文推断角色,而非使用固定的角色引用。", "template.settingsModal.display.showInventory": "显示物品栏", @@ -46,6 +52,8 @@ "template.settingsModal.display.showLockIconsNote": "在跟踪器项目上显示锁定/解锁图标,以防止 AI 修改它们。", "template.settingsModal.display.showThoughtsInChat": "显示想法", "template.settingsModal.display.showThoughtsInChatNote": "将角色想法显示为其消息旁边的气泡。", + "template.settingsModal.display.showInlineThoughts": "在消息文本下方显示想法", + "template.settingsModal.display.showInlineThoughtsNote": "在默认角落想法气泡和显示在消息文本下方的想法卡片之间切换。", "template.settingsModal.display.alwaysShowThoughtBubble": "始终显示想法气泡", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "自动展开想法气泡,无需先点击图标", "template.settingsModal.display.enableAnimations": "启用动画", @@ -482,4 +490,4 @@ "userStats.clickToEdit": "点击编辑", "quests.main.addQuestTitle": "添加主线任务", "quests.optional.addQuestTitle": "添加可选任务" -} \ No newline at end of file +} diff --git a/src/i18n/zh-tw.json b/src/i18n/zh-tw.json index ce2a6c8..9be190d 100644 --- a/src/i18n/zh-tw.json +++ b/src/i18n/zh-tw.json @@ -32,11 +32,19 @@ "template.settingsModal.display.showUserStats": "顯示 user 屬性", "template.settingsModal.display.showInfoBox": "顯示資訊框", "template.settingsModal.display.showPresentCharacters": "顯示在場角色", + "template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色", + "template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。", + "template.settingsModal.display.thoughtBasedExpressions": "基於想法的表情", + "template.settingsModal.display.thoughtBasedExpressionsNote": "使用 SillyTavern Character Expressions 對聊天下方面板中每個在場角色的想法進行分類。Token 用量可能會依所選的分類 API 而增加。", + "template.settingsModal.display.hideDefaultExpressionDisplay": "隱藏預設表情顯示", + "template.settingsModal.display.hideDefaultExpressionDisplayNote": "隱藏 SillyTavern 內建的角色表情顯示。", "template.settingsModal.display.showInventory": "顯示物品欄", "template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器", "template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。", "template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法", "template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡", + "template.settingsModal.display.showInlineThoughts": "在訊息文字下方顯示想法", + "template.settingsModal.display.showInlineThoughtsNote": "在預設角落想法泡泡與顯示在訊息文字下方的想法卡片之間切換。", "template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡", "template.settingsModal.display.enableAnimations": "啟用動畫", diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 89aa076..164e9a6 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -21,7 +21,7 @@ import { $musicPlayerContainer, getSeparateGenerationId } from '../../core/state.js'; -import { saveChatData, mirrorToSwipeInfo } from '../../core/persistence.js'; +import { saveChatData, setMessageSwipeTrackerData } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; @@ -326,15 +326,11 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough } const currentSwipeId = lastMessage.swipe_id || 0; - const swipeEntry = { + setMessageSwipeTrackerData(lastMessage, currentSwipeId, { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts - }; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; - - // Mirror to swipe_info so this swipe survives page reload even if never manually edited - mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); + }); // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); } diff --git a/src/systems/generation/encounterPrompts.js b/src/systems/generation/encounterPrompts.js index 2e041d1..e858e3f 100644 --- a/src/systems/generation/encounterPrompts.js +++ b/src/systems/generation/encounterPrompts.js @@ -9,6 +9,7 @@ import { selected_group, getGroupMembers, groups } from '../../../../../../group import { extensionSettings, committedTrackerData } from '../../core/state.js'; import { currentEncounter } from '../features/encounterState.js'; import { repairJSON } from '../../utils/jsonRepair.js'; +import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js'; import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js'; import { applyLocks } from './lockManager.js'; @@ -709,7 +710,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) { summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`; // If in Together mode and trackers are enabled, add tracker update instructions - if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) { + if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled())) { summaryMessage += `\n--- TRACKER UPDATE ---\n\n`; summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `; summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`; diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index 3c73b99..440cce5 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -14,6 +14,7 @@ import { addLockInstruction } from './jsonPromptHelpers.js'; import { applyLocks } from './lockManager.js'; +import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js'; // Type imports /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ @@ -293,7 +294,7 @@ export function generateTrackerExample() { } } - if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { + if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) { try { JSON.parse(committedTrackerData.characterThoughts); const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters'); @@ -329,7 +330,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon let instructions = ''; // Check if any trackers are enabled - const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts; + const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled(); // Only add tracker instructions if at least one tracker is enabled if (hasAnyTrackers) { @@ -360,7 +361,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon if (extensionSettings.showInfoBox) { enabledTrackers.push('infoBox'); } - if (extensionSettings.showCharacterThoughts) { + if (isPresentCharactersEnabled()) { enabledTrackers.push('characters'); } @@ -383,7 +384,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n'; } - if (extensionSettings.showCharacterThoughts) { + if (isPresentCharactersEnabled()) { instructions += ' "characters": '; const charactersJSON = buildCharactersJSONInstruction(); // Add 2 spaces to all lines after the first to properly nest within root object @@ -1061,7 +1062,7 @@ export function generateContextualSummary() { } // Add Present Characters tracker data if enabled - if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { + if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) { try { const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName); if (formatted) { diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index d55d8f8..518a527 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -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 { @@ -23,7 +23,18 @@ import { $musicPlayerContainer, incrementSeparateGenerationId } from '../../core/state.js'; -import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, inheritSwipeDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; +import { + saveChatData, + loadChatData, + autoSwitchPresetForEntity, + getMessageSwipeTrackerData, + getCurrentMessageSwipeTrackerData, + restoreLatestTrackerStateFromChat, + setMessageSwipeTrackerData, + getSwipeData, + commitTrackerDataFromPriorMessage, + inheritSwipeDataFromPriorMessage +} from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; // Generation & Parsing @@ -52,6 +63,8 @@ import { updateStripWidgets } from '../ui/desktop.js'; import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; +let chatStateRehydrateRunId = 0; + /** * Reads the swipe store of the last assistant message in `currentChat` and * writes its data into `lastGeneratedData`, including syncing stat bars via @@ -133,6 +146,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. @@ -228,15 +472,11 @@ export async function onMessageReceived(data) { } const currentSwipeId = lastMessage.swipe_id || 0; - const swipeEntry = { + setMessageSwipeTrackerData(lastMessage, currentSwipeId, { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts - }; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; - - // Mirror to swipe_info so this swipe survives page reload even if never manually edited - mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); + }); // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); @@ -283,6 +523,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(); } @@ -301,8 +546,8 @@ export async function onMessageReceived(data) { renderMusicPlayer($musicPlayerContainer[0]); } - // When auto-update is disabled, no tracker API call will run for this message. - // Inherit the prior assistant message's tracker data into this swipe slot so that + // When auto-update is disabled, no tracker API call will run for this message. + // Inherit the prior assistant message's tracker data into this swipe slot so that // commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything. // Inheritance does not overwrite existing data, so it's safe to call even if the condition misses an edge case. if (!extensionSettings.autoUpdate || !isAwaitingNewMessage) { @@ -362,6 +607,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'); @@ -396,20 +642,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(); @@ -434,6 +669,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) @@ -441,6 +677,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 @@ -455,13 +693,15 @@ 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); - // Load saved swipe data into both display (lastGeneratedData) and extensionSettings. - // Safe to call parseUserStats() unconditionally because updateMessageSwipeData() is called - // on every manual edit, so the swipe store always reflects the latest user changes before - // any navigation can overwrite them. - const swipeData = getSwipeData(message, currentSwipeId); + // Load saved swipe data for the active swipe only. + // Using the current-swipe helper here avoids falling back to another + // stored swipe payload and showing stale tracker state. if (swipeData) { // Load swipe data into lastGeneratedData for display (both modes) lastGeneratedData.userStats = swipeData.userStats || null; @@ -500,24 +740,16 @@ export function onMessageSwiped(messageIndex) { updateChatThoughts(); } -/** - * Event handler for when a message is deleted. - * Re-syncs lastGeneratedData, committedTrackerData, and all UI panels to the - * new last assistant message's active swipe — or clears everything if no - * assistant messages remain. - */ export function onMessageDeleted() { - if (!extensionSettings.enabled) return; - - // console.log('[RPG Companion] 🗑️ EVENT: onMessageDeleted'); + if (!extensionSettings.enabled) { + return; + } // Invalidate any pending or in-flight separate-mode generation so // its result is not applied to the (now-changed) chat tail. incrementSeparateGenerationId(); - const currentChat = getContext().chat; - - // Walk backward to find the new last assistant message. + const currentChat = getContext().chat || []; let lastAssistantIndex = -1; for (let i = currentChat.length - 1; i >= 0; i--) { if (!currentChat[i].is_user && !currentChat[i].is_system) { @@ -526,48 +758,15 @@ export function onMessageDeleted() { } } - if (lastAssistantIndex === -1) { - // No assistant messages remain — clear all state. - lastGeneratedData.userStats = null; - lastGeneratedData.infoBox = null; - lastGeneratedData.characterThoughts = null; - committedTrackerData.userStats = null; - committedTrackerData.infoBox = null; - committedTrackerData.characterThoughts = null; - // console.log('[RPG Companion] 🗑️ No assistant messages remain — cleared all tracker state.'); - } else { - // Restore display state from the new tail message's active swipe. - // If the message has no swipe data yet, null the fields so we - // don't show stale data from the deleted message. - const hadSwipeData = syncLastGeneratedDataFromSwipeStore(currentChat); - if (!hadSwipeData) { - lastGeneratedData.userStats = null; - lastGeneratedData.infoBox = null; - lastGeneratedData.characterThoughts = null; - committedTrackerData.userStats = null; - committedTrackerData.infoBox = null; - committedTrackerData.characterThoughts = null; - // console.log('[RPG Companion] 🗑️ No swipe data for last assistant message — cleared display state.'); - } + syncDisplayedTrackerStateFromChat(); - // Commit context from the message *before* the new tail assistant message, - // so any subsequent generation uses the correct N-1 world state. + // After the display state has been rebuilt, restore generation context from + // the assistant message immediately before the new tail message so the next + // generation uses the correct N-1 tracker state. + if (lastAssistantIndex !== -1) { commitTrackerDataFromPriorMessage(lastAssistantIndex); } - // Re-render all panels. - renderUserStats(); - renderInfoBox(); - renderThoughts(); - renderInventory(); - renderQuests(); - renderMusicPlayer($musicPlayerContainer[0]); - - // Update widget strips. - updateFabWidgets(); - updateStripWidgets(); - - // Persist updated state. saveChatData(); } diff --git a/src/systems/integration/thoughtBasedExpressions.js b/src/systems/integration/thoughtBasedExpressions.js new file mode 100644 index 0000000..0ce0f16 --- /dev/null +++ b/src/systems/integration/thoughtBasedExpressions.js @@ -0,0 +1,550 @@ +/** + * Thought-based Character Expressions for the below-chat Present Characters panel. + * + * Derives portrait expressions from the current Present Characters thoughts + * payload, while keeping SillyTavern's native Character Expressions widget + * independent from the below-chat panel. + */ + +import { getContext } from '../../../../../../extensions.js'; +import { + extensionSettings, + thoughtBasedExpressionPortraits, + setThoughtBasedExpressionPortraits +} from '../../core/state.js'; +import { + getCurrentMessageSwipeTrackerData, + saveChatData, + setMessageSwipeTrackerField +} from '../../core/persistence.js'; +import { isUsableThoughtBasedExpressionSrc } from '../../utils/thoughtBasedExpressionPortraits.js'; +import { + getPresentCharactersTrackerData, + parsePresentCharacters +} from '../../utils/presentCharacters.js'; +import { + classifyExpressionText, + clearExpressionsCompatibilityCache, + getExpressionClassificationSettingsSignature, + getExpressionPortraitSettingsSignature, + getExpressionsSettingsSignature, + isExpressionsExtensionEnabled, + resolveSpriteFolderNameForCharacter, + resolveExpressionPortraitForCharacter +} from '../../utils/sillyTavernExpressions.js'; + +const OFF_SCENE_THOUGHT_PATTERN = /\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; +const CHAT_CHANGE_RETRY_DELAYS = [0, 80, 220, 500]; +const REFRESH_DEBOUNCE_DELAY = 80; +const THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION = 1; +const THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD = 'thoughtBasedExpressions'; + +let hiddenExpressionStyleElement = null; +let thoughtBasedExpressionsRefreshHandler = null; +let scheduledRefreshTimer = null; +let activeRefreshRunId = 0; +let lastCompletedRefreshSignature = null; +let lastExpressionSettingsSignature = null; + +function normalizeName(name) { + return String(name || '').trim().toLowerCase(); +} + +function shouldHideNativeExpressionDisplay() { + return extensionSettings.enabled === true && extensionSettings.hideDefaultExpressionDisplay === true; +} + +function shouldUseThoughtBasedExpressions() { + return extensionSettings.enabled === true + && extensionSettings.enableThoughtBasedExpressions === true + && extensionSettings.showAlternatePresentCharactersPanel === true; +} + +function notifyThoughtBasedExpressionsConsumers() { + thoughtBasedExpressionsRefreshHandler?.(); +} + +function getHideStyleCss() { + return ` +#expression-image, +#expression-holder, +.expression-holder, +[data-expression-container], +#expression-image img, +#expression-holder img, +.expression-holder img, +[data-expression-container] img { + position: absolute !important; + left: -10000px !important; + top: 0 !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + opacity: 0 !important; + pointer-events: none !important; + visibility: hidden !important; +} +`; +} + +function hideNativeExpressionDisplay() { + if (hiddenExpressionStyleElement?.isConnected) { + return; + } + + const styleElement = document.createElement('style'); + styleElement.id = 'rpg-hidden-native-expression-display-style'; + styleElement.textContent = getHideStyleCss(); + document.head.appendChild(styleElement); + hiddenExpressionStyleElement = styleElement; +} + +function showNativeExpressionDisplay() { + if (hiddenExpressionStyleElement?.isConnected) { + hiddenExpressionStyleElement.remove(); + } else { + document.getElementById('rpg-hidden-native-expression-display-style')?.remove(); + } + + hiddenExpressionStyleElement = null; +} + +function updateNativeExpressionDisplayVisibility() { + if (shouldHideNativeExpressionDisplay()) { + hideNativeExpressionDisplay(); + } else { + showNativeExpressionDisplay(); + } +} + +function clearScheduledRefresh() { + if (scheduledRefreshTimer !== null) { + clearTimeout(scheduledRefreshTimer); + scheduledRefreshTimer = null; + } +} + +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 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 normalizeExpressionLabel(label) { + return String(label || '').trim().toLowerCase(); +} + +function arePortraitMapsEqual(left, right) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every(key => left[key] === right[key]); +} + +function applyThoughtBasedExpressionPortraits(nextPortraits) { + if (arePortraitMapsEqual(thoughtBasedExpressionPortraits, nextPortraits)) { + return false; + } + + setThoughtBasedExpressionPortraits(nextPortraits); + return true; +} + +function purgeInvalidThoughtBasedExpressionPortraits() { + const nextPortraits = {}; + + for (const [characterName, src] of Object.entries(thoughtBasedExpressionPortraits)) { + if (isUsableThoughtBasedExpressionSrc(src)) { + nextPortraits[characterName] = src; + } + } + + return applyThoughtBasedExpressionPortraits(nextPortraits); +} + +function getMessageThoughtPayload(message) { + if (!message || message.is_user) { + return null; + } + + const swipeData = getCurrentMessageSwipeTrackerData(message); + return normalizeThoughtPayload(swipeData?.characterThoughts ?? null); +} + +function findThoughtSourceMessageInfo(characterThoughtsData) { + const chatMessages = getContext()?.chat || []; + const currentThoughts = normalizeThoughtPayload(characterThoughtsData); + let fallback = null; + + for (let i = chatMessages.length - 1; i >= 0; i--) { + const message = chatMessages[i]; + if (!message || message.is_user || message.is_system) { + continue; + } + + const swipeData = getCurrentMessageSwipeTrackerData(message); + if (!swipeData) { + continue; + } + + const sourceInfo = { + message, + messageIndex: i, + swipeId: Number(message.swipe_id ?? 0), + swipeData + }; + + if (!fallback) { + fallback = sourceInfo; + } + + const messageThoughts = getMessageThoughtPayload(message); + if (currentThoughts && messageThoughts === currentThoughts) { + return sourceInfo; + } + } + + return currentThoughts ? null : fallback; +} + +function isThoughtBasedExpressionsCache(candidate) { + return !!( + candidate + && typeof candidate === 'object' + && !Array.isArray(candidate) + && candidate.version === THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION + && candidate.entries + && typeof candidate.entries === 'object' + && !Array.isArray(candidate.entries) + ); +} + +function getSwipeThoughtBasedExpressionsCache(sourceInfo) { + const directCache = sourceInfo?.swipeData?.[THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD]; + return isThoughtBasedExpressionsCache(directCache) ? directCache : null; +} + +function areThoughtBasedExpressionsCachesEqual(left, right) { + return stableStringify(left) === stableStringify(right); +} + +function getThoughtBasedExpressionEntries(characterThoughtsData) { + const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts; + if (thoughtsConfig?.enabled === false) { + return []; + } + + if (!characterThoughtsData) { + return []; + } + + const presentCharacters = parsePresentCharacters(characterThoughtsData); + return presentCharacters + .map(character => ({ + name: String(character?.name || '').trim(), + thought: String(character?.ThoughtsContent || '').trim() + })) + .filter(character => character.name && character.thought && !OFF_SCENE_THOUGHT_PATTERN.test(character.thought)); +} + +function buildRefreshSignature(thoughtEntries, expressionsSettingsSignature) { + return JSON.stringify({ + expressionsSettingsSignature, + thoughtEntries: thoughtEntries.map(entry => ({ + name: normalizeName(entry.name), + thought: entry.thought, + spriteFolderName: resolveSpriteFolderNameForCharacter(entry.name) + })) + }); +} + +async function refreshThoughtBasedExpressions({ force = false } = {}) { + updateNativeExpressionDisplayVisibility(); + + if (!extensionSettings.enabled) { + showNativeExpressionDisplay(); + return; + } + + if (!shouldUseThoughtBasedExpressions()) { + return; + } + + if (!isExpressionsExtensionEnabled()) { + lastCompletedRefreshSignature = null; + lastExpressionSettingsSignature = null; + clearExpressionsCompatibilityCache(); + const portraitsChanged = applyThoughtBasedExpressionPortraits({}); + if (portraitsChanged) { + saveChatData(); + } + notifyThoughtBasedExpressionsConsumers(); + return; + } + + const expressionsSettingsSignature = getExpressionsSettingsSignature(); + if (expressionsSettingsSignature !== lastExpressionSettingsSignature) { + clearExpressionsCompatibilityCache(); + lastExpressionSettingsSignature = expressionsSettingsSignature; + lastCompletedRefreshSignature = null; + } + + const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback: true }); + const thoughtEntries = getThoughtBasedExpressionEntries(characterThoughtsData); + const refreshSignature = buildRefreshSignature(thoughtEntries, expressionsSettingsSignature); + if (!force && refreshSignature === lastCompletedRefreshSignature) { + return; + } + + const sourceInfo = findThoughtSourceMessageInfo(characterThoughtsData); + const cachedThoughtBasedExpressions = getSwipeThoughtBasedExpressionsCache(sourceInfo); + const cachedEntries = cachedThoughtBasedExpressions?.entries && typeof cachedThoughtBasedExpressions.entries === 'object' && !Array.isArray(cachedThoughtBasedExpressions.entries) + ? cachedThoughtBasedExpressions.entries + : {}; + const currentThoughtsSignature = normalizeThoughtPayload(characterThoughtsData); + const classificationSettingsSignature = getExpressionClassificationSettingsSignature(); + const portraitSettingsSignature = getExpressionPortraitSettingsSignature(); + const runId = ++activeRefreshRunId; + const nextPortraits = {}; + const nextCacheEntries = {}; + + for (const entry of thoughtEntries) { + const portraitKey = normalizeName(entry.name); + if (!portraitKey) { + continue; + } + + const spriteFolderName = resolveSpriteFolderNameForCharacter(entry.name); + const cachedEntry = cachedEntries[portraitKey] && typeof cachedEntries[portraitKey] === 'object' + ? cachedEntries[portraitKey] + : null; + const previousSrc = nextPortraits[portraitKey] || thoughtBasedExpressionPortraits[portraitKey] || null; + const canReuseExpression = cachedEntry + && cachedEntry.thought === entry.thought + && cachedEntry.classificationSettingsSignature === classificationSettingsSignature + && cachedEntry.spriteFolderName === spriteFolderName + && typeof cachedEntry.expression === 'string'; + + const expression = canReuseExpression + ? normalizeExpressionLabel(cachedEntry.expression) + : normalizeExpressionLabel(await classifyExpressionText(entry.thought, { characterName: entry.name })); + if (runId !== activeRefreshRunId) { + return; + } + + const canReusePortrait = cachedEntry + && cachedEntry.thought === entry.thought + && cachedEntry.expression === expression + && cachedEntry.portraitSettingsSignature === portraitSettingsSignature + && cachedEntry.spriteFolderName === spriteFolderName + && cachedEntry.portraitResolved === true; + + const portraitSrc = canReusePortrait + ? (isUsableThoughtBasedExpressionSrc(cachedEntry.portraitSrc) ? cachedEntry.portraitSrc : null) + : await resolveExpressionPortraitForCharacter(entry.name, expression, { previousSrc }); + if (runId !== activeRefreshRunId) { + return; + } + + if (isUsableThoughtBasedExpressionSrc(portraitSrc)) { + nextPortraits[portraitKey] = portraitSrc; + } + + nextCacheEntries[portraitKey] = { + name: entry.name, + thought: entry.thought, + spriteFolderName, + classificationSettingsSignature, + portraitSettingsSignature, + expression, + portraitSrc: isUsableThoughtBasedExpressionSrc(portraitSrc) ? portraitSrc : null, + portraitResolved: true + }; + } + + if (runId !== activeRefreshRunId) { + return; + } + + let cacheChanged = false; + if (sourceInfo) { + const nextCache = { + version: THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION, + thoughtsSignature: currentThoughtsSignature, + entries: nextCacheEntries + }; + + if (!areThoughtBasedExpressionsCachesEqual(cachedThoughtBasedExpressions, nextCache)) { + setMessageSwipeTrackerField(sourceInfo.message, sourceInfo.swipeId, THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD, nextCache); + cacheChanged = true; + } + } + + lastCompletedRefreshSignature = refreshSignature; + const portraitsChanged = applyThoughtBasedExpressionPortraits(nextPortraits); + if (portraitsChanged || cacheChanged) { + saveChatData(); + } + if (portraitsChanged) { + notifyThoughtBasedExpressionsConsumers(); + } +} + +export function setThoughtBasedExpressionsRefreshHandler(handler) { + thoughtBasedExpressionsRefreshHandler = typeof handler === 'function' ? handler : null; +} + +export function queueThoughtBasedExpressionsUpdate({ immediate = false, force = false } = {}) { + clearScheduledRefresh(); + + const runRefresh = () => { + refreshThoughtBasedExpressions({ force }).catch(error => { + console.warn('[RPG Companion] Thought-based expressions update failed:', error); + }); + }; + + if (immediate) { + runRefresh(); + return; + } + + scheduledRefreshTimer = setTimeout(() => { + scheduledRefreshTimer = null; + runRefresh(); + }, REFRESH_DEBOUNCE_DELAY); +} + +export function initThoughtBasedExpressions() { + const purged = purgeInvalidThoughtBasedExpressionPortraits(); + updateNativeExpressionDisplayVisibility(); + + if (purged) { + saveChatData(); + notifyThoughtBasedExpressionsConsumers(); + } + + if (shouldUseThoughtBasedExpressions()) { + queueThoughtBasedExpressionsUpdate({ immediate: true, force: true }); + } +} + +export function onThoughtBasedExpressionsChatChanged() { + if (!extensionSettings.enabled) { + showNativeExpressionDisplay(); + return; + } + + clearScheduledRefresh(); + activeRefreshRunId += 1; + lastCompletedRefreshSignature = null; + lastExpressionSettingsSignature = null; + clearExpressionsCompatibilityCache(); + + const purged = purgeInvalidThoughtBasedExpressionPortraits(); + if (purged) { + saveChatData(); + notifyThoughtBasedExpressionsConsumers(); + } + + for (const delay of CHAT_CHANGE_RETRY_DELAYS) { + setTimeout(() => { + updateNativeExpressionDisplayVisibility(); + if (shouldUseThoughtBasedExpressions()) { + queueThoughtBasedExpressionsUpdate({ immediate: true, force: true }); + } else { + notifyThoughtBasedExpressionsConsumers(); + } + }, delay); + } +} + +export function onThoughtBasedExpressionsSettingChanged(enabled) { + updateNativeExpressionDisplayVisibility(); + + if (enabled) { + const purged = purgeInvalidThoughtBasedExpressionPortraits(); + if (purged) { + saveChatData(); + notifyThoughtBasedExpressionsConsumers(); + } + + if (shouldUseThoughtBasedExpressions()) { + queueThoughtBasedExpressionsUpdate({ immediate: true, force: true }); + } else { + notifyThoughtBasedExpressionsConsumers(); + } + return; + } + + clearScheduledRefresh(); + activeRefreshRunId += 1; + lastCompletedRefreshSignature = null; + lastExpressionSettingsSignature = null; + clearExpressionsCompatibilityCache(); + notifyThoughtBasedExpressionsConsumers(); +} + +export function onAlternatePresentCharactersVisibilityChanged() { + updateNativeExpressionDisplayVisibility(); + + if (shouldUseThoughtBasedExpressions()) { + queueThoughtBasedExpressionsUpdate({ immediate: true, force: true }); + return; + } + + clearScheduledRefresh(); + activeRefreshRunId += 1; + lastCompletedRefreshSignature = null; + lastExpressionSettingsSignature = null; +} + +export function onHideDefaultExpressionDisplaySettingChanged(enabled) { + extensionSettings.hideDefaultExpressionDisplay = enabled === true; + updateNativeExpressionDisplayVisibility(); + setTimeout(() => updateNativeExpressionDisplayVisibility(), 0); + setTimeout(() => updateNativeExpressionDisplayVisibility(), 120); +} + +export function clearThoughtBasedExpressionsCache() { + clearScheduledRefresh(); + activeRefreshRunId += 1; + lastCompletedRefreshSignature = null; + lastExpressionSettingsSignature = null; + clearExpressionsCompatibilityCache(); + showNativeExpressionDisplay(); +} diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js index f881639..6eacb2e 100644 --- a/src/systems/rendering/infoBox.js +++ b/src/systems/rendering/infoBox.js @@ -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; diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js index 8e3b27d..fb66c63 100644 --- a/src/systems/rendering/thoughts.js +++ b/src/systems/rendering/thoughts.js @@ -4,20 +4,25 @@ */ import { getContext } from '../../../../../../extensions.js'; -import { this_chid, characters } from '../../../../../../../script.js'; -import { selected_group, getGroupMembers } from '../../../../../../group-chats.js'; import { extensionSettings, lastGeneratedData, committedTrackerData, $thoughtsContainer, - FALLBACK_AVATAR_DATA_URI, addDebugLog } from '../../core/state.js'; import { i18n } from '../../core/i18n.js'; -import { saveChatData, saveSettings } from '../../core/persistence.js'; -import { getSafeThumbnailUrl } from '../../utils/avatars.js'; +import { saveChatData, saveSettings, getCurrentMessageSwipeTrackerData, setMessageSwipeTrackerField } from '../../core/persistence.js'; +import { + stripBrackets, + extractFieldValue, + toSnakeCase, + getPresentCharactersTrackerData, + resolvePresentCharacterPortrait +} from '../../utils/presentCharacters.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js'; +import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js'; +import { queueThoughtBasedExpressionsUpdate } from '../integration/thoughtBasedExpressions.js'; /** * Helper to generate lock icon HTML if setting is enabled @@ -81,80 +86,15 @@ function getStatColor(percentage, lowColor, highColor, lowOpacity = 100, highOpa return `rgba(${r}, ${g}, ${b}, ${a})`; } -/** - * Strips leading and trailing square brackets from a string value. - * Used to clean placeholder notation that AI might include in responses. - * @param {string} value - The value to clean - * @returns {string} Cleaned value without surrounding brackets - */ -function stripBrackets(value) { - if (typeof value !== 'string') return value; - return value.replace(/^\[|\]$/g, '').trim(); -} - -/** - * Extracts the actual value from a field that might be locked. - * If the field is an object with {value, locked}, returns the value. - * Otherwise returns the field as-is. - * @param {any} fieldValue - The field value (might be string or {value, locked} object) - * @returns {string} The actual string value - */ -function extractFieldValue(fieldValue) { - if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) { - return fieldValue.value || ''; - } - return fieldValue || ''; -} - -/** - * Converts a field name to snake_case for use as JSON key - * Example: "Test Tracker" -> "test_tracker" - * @param {string} name - Field name to convert - * @returns {string} snake_case version - */ -function toSnakeCase(name) { - return name - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, '_') - .replace(/^_+|_+$/g, ''); -} - -/** - * Fuzzy name matching that handles: - * - Exact matches: "Sabrina" === "Sabrina" - * - Parenthetical additions: "Sabrina" matches "Sabrina (Margrokha's Avatar)" - * - Title additions: "Sabrina" matches "Princess Sabrina" - * - Word boundaries: "Sabrina" won't match "Sabrina's Mother" - * - * @param {string} cardName - Name from the character card - * @param {string} aiName - Name generated by the AI - * @returns {boolean} True if names match - */ -function namesMatch(cardName, aiName) { - if (!cardName || !aiName) return false; - - // 1. Exact match (fast path) - if (cardName.toLowerCase() === aiName.toLowerCase()) return true; - - // 2. Strip parentheses and match - const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim(); - const cardCore = stripParens(cardName).toLowerCase(); - const aiCore = stripParens(aiName).toLowerCase(); - if (cardCore === aiCore) return true; - - // 3. Check if card name appears as complete word in AI name - // Escape special regex characters to prevent "Invalid regular expression" errors - const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`); - return wordBoundary.test(aiCore); -} - /** * Renders character thoughts (Present Characters) panel. * Displays character cards with avatars, relationship badges, and traits. * Includes event listeners for editable character fields. */ -export function renderThoughts({ preserveScroll = false } = {}) { +export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) { + renderAlternatePresentCharacters({ useCommittedFallback }); + queueThoughtBasedExpressionsUpdate(); + if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { return; } @@ -169,7 +109,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 = getPresentCharactersTrackerData({ useCommittedFallback }); if (!thoughtsData) { $thoughtsContainer.html('