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('
' + (i18n.getTranslation('thoughts.empty') || 'No character data generated yet') + '
'); return; @@ -193,7 +133,7 @@ export function renderThoughts({ preserveScroll = false } = {}) { const hasRelationshipEnabled = relationshipFields.length > 0; // Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) - const characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || ''; + const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback }); // console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts)); // console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts)); @@ -416,70 +356,8 @@ export function renderThoughts({ preserveScroll = false } = {}) { try { debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name); - // Find character portrait - // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors - let characterPortrait = FALLBACK_AVATAR_DATA_URI; - debugLog(`[RPG Thoughts] Looking up avatar for: ${char.name}`); - - // First, check if user manually uploaded a custom avatar - if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[char.name]) { - characterPortrait = extensionSettings.npcAvatars[char.name]; - debugLog('[RPG Thoughts] Found custom uploaded avatar'); - } - - // For group chats, search through group members - if (characterPortrait === FALLBACK_AVATAR_DATA_URI && selected_group) { - debugLog('[RPG Thoughts] In group chat, checking group members...'); - - try { - const groupMembers = getGroupMembers(selected_group); - debugLog('[RPG Thoughts] Group members count:', groupMembers ? groupMembers.length : 0); - - if (groupMembers && groupMembers.length > 0) { - const matchingMember = groupMembers.find(member => - member && member.name && namesMatch(member.name, char.name) - ); - - if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - debugLog('[RPG Thoughts] Found avatar in group members'); - } - } - } - } catch (groupError) { - debugLog('[RPG Thoughts] Error checking group members:', groupError.message); - } - } - - // For regular chats or if not found in group, search all characters - if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { - debugLog('[RPG Thoughts] Searching all characters...'); - - const matchingCharacter = characters.find(c => - c && c.name && namesMatch(c.name, char.name) - ); - - if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - debugLog('[RPG Thoughts] Found avatar in all characters'); - } - } - } - - // If this is the current character in a 1-on-1 chat, use their portrait - if (this_chid !== undefined && characters[this_chid] && - characters[this_chid].name && namesMatch(characters[this_chid].name, char.name)) { - const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - debugLog('[RPG Thoughts] Found avatar from current character'); - } - } + const characterPortrait = resolvePresentCharacterPortrait(char.name); debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...'); @@ -632,7 +510,9 @@ export function renderThoughts({ preserveScroll = false } = {}) { // Update icon const newIcon = !currentlyLocked ? '🔒' : '🔓'; - const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked'; + const newTitle = !currentlyLocked + ? (i18n.getTranslation('thoughts.locked') || 'Locked') + : (i18n.getTranslation('thoughts.unlocked') || 'Unlocked'); $icon.text(newIcon); $icon.attr('title', newTitle); @@ -839,7 +719,7 @@ export function removeCharacter(characterName) { if (message.extra && message.extra.rpg_companion_swipes) { const swipeId = message.swipe_id || 0; if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; + setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts); } } break; @@ -969,7 +849,7 @@ export function addNewCharacter() { if (message.extra && message.extra.rpg_companion_swipes) { const swipeId = message.swipe_id || 0; if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; + setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts); } } break; @@ -1152,7 +1032,7 @@ export function updateCharacterField(characterName, field, value) { if (message.extra && message.extra.rpg_companion_swipes) { const swipeId = message.swipe_id || 0; if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; + setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts); } } break; @@ -1381,7 +1261,7 @@ export function updateCharacterField(characterName, field, value) { if (message.extra && message.extra.rpg_companion_swipes) { const swipeId = message.swipe_id || 0; if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].characterThoughts = lines.join('\n'); + setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lines.join('\n')); } } break; @@ -1405,6 +1285,10 @@ export function updateCharacterField(characterName, field, value) { // console.log('[RPG Companion] Is editing thoughts?', isEditingThoughts, 'Field:', field, 'Thoughts field name:', thoughtsFieldName); // console.log('[RPG Companion] After update - lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + if (field === 'name' || isEditingThoughts) { + queueThoughtBasedExpressionsUpdate({ immediate: true, force: true }); + } + if (isEditingThoughts && extensionSettings.showThoughtsInChat) { // console.log('[RPG Companion] Updating chat thought bubbles'); // Update chat thought bubbles when thoughts are edited @@ -1438,66 +1322,265 @@ function renderThoughtsSidebarOnly() { } /** - * Updates or removes thought overlays in the chat. - * Creates floating thought bubbles positioned near character avatars. + * Updates or removes thoughts shown in chat. + * Renders either the original corner bubbles or inline dropdown cards. */ -export function updateChatThoughts() { - // console.log('[RPG Companion] ======== updateChatThoughts called ========'); - // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled); - // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat); - // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked')); - // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); - // Remove existing thought panel and icon +let inlineThoughtsObserver = null; +let inlineThoughtsRefreshTimeout = null; +let isRefreshingInlineThoughts = false; + +export function updateChatThoughts(attempt = 0) { + const thoughtsStyle = extensionSettings.thoughtsInChatStyle || 'corner'; + const openInlineThoughts = getOpenInlineThoughts(); + + // Remove old floating thought panel/icon (legacy cleanup) $('#rpg-thought-panel').remove(); $('#rpg-thought-icon').remove(); $('#chat').off('scroll.thoughtPanel'); $(window).off('resize.thoughtPanel'); $(document).off('click.thoughtPanel'); - // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return - if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { - // console.log('[RPG Companion] Thoughts in chat disabled or no data'); + // Remove any existing inline thought dropdowns from previous renders + $('.rpg-inline-thoughts, .rpg-inline-thought').remove(); + + const canRenderThoughts = extensionSettings.enabled + && extensionSettings.showThoughtsInChat + && !!lastGeneratedData.characterThoughts; + + if (!canRenderThoughts) { + teardownInlineThoughtsObserver(); return; } - // Parse the Present Characters data to get thoughts - let thoughtsArray = []; // Array of {name, emoji, thought} + const thoughtsArray = parseThoughtsArray(); + + if (thoughtsArray.length === 0) { + teardownInlineThoughtsObserver(); + return; + } + + const targetInfo = findThoughtTargetMessage(); + let $targetMessage = targetInfo.$message; + + if ((!$targetMessage || !$targetMessage.length || !$targetMessage.find('.mes_text').length) && attempt < 10) { + setTimeout(() => updateChatThoughts(attempt + 1), 120); + return; + } + + if (!$targetMessage || !$targetMessage.length) { + teardownInlineThoughtsObserver(); + return; + } + + if (thoughtsStyle === 'inline') { + insertInlineThoughts($targetMessage, thoughtsArray, openInlineThoughts); + ensureInlineThoughtsObserver(); + } else { + teardownInlineThoughtsObserver(); + createThoughtPanel($targetMessage, thoughtsArray); + } +} + +function findThoughtTargetMessage() { + const context = getContext(); + const chat = context?.chat || []; + const currentThoughts = normalizeThoughtPayload(lastGeneratedData.characterThoughts); + + let fallbackIndex = -1; + + // Match the currently displayed thoughts against stored swipe payloads so the + // UI stays attached to the visible assistant reply after swipes, deletes, and reloads. + for (let i = chat.length - 1; i >= 0; i--) { + const message = chat[i]; + if (message?.is_user) continue; + + if (fallbackIndex === -1) { + fallbackIndex = i; + } + + const messageThoughts = getMessageThoughtPayload(message); + if (!currentThoughts || !messageThoughts) continue; + + if (messageThoughts === currentThoughts) { + const $message = $(`#chat .mes[mesid="${i}"]`); + return { index: i, $message }; + } + } + + if (fallbackIndex !== -1) { + return { + index: fallbackIndex, + $message: $(`#chat .mes[mesid="${fallbackIndex}"]`) + }; + } + + return { index: -1, $message: $() }; +} + +function getMessageThoughtPayload(message) { + if (!message || message.is_user) { + return null; + } + + const swipeData = getCurrentMessageSwipeTrackerData(message); + return normalizeThoughtPayload(swipeData?.characterThoughts ?? null); +} + +function normalizeThoughtPayload(payload) { + if (!payload) { + return null; + } + + if (typeof payload === 'object') { + return stableStringify(payload); + } + + if (typeof payload !== 'string') { + return String(payload); + } + + const trimmed = payload.trim(); + if (!trimmed) { + return null; + } + + try { + return stableStringify(JSON.parse(trimmed)); + } catch { + return trimmed.replace(/\r\n/g, '\n'); + } +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(item => stableStringify(item)).join(',')}]`; + } + + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; + } + + return JSON.stringify(value); +} + +function getOpenInlineThoughts() { + const openThoughts = new Set(); + + $('.rpg-inline-thought[open]').each(function () { + const characterName = ($(this).attr('data-character') || '').trim(); + if (characterName) { + openThoughts.add(characterName); + } + }); + + return openThoughts; +} + +function ensureInlineThoughtsObserver() { + if (inlineThoughtsObserver) { + return; + } + + const chatElement = document.getElementById('chat'); + if (!chatElement) { + return; + } + + inlineThoughtsObserver = new MutationObserver((mutations) => { + if (isRefreshingInlineThoughts) { + return; + } + + // SillyTavern rerenders message DOM after swipes, deletes, and message cleanup. + // Watch for those chat-level mutations and reattach inline thoughts once the + // target message body exists again, while ignoring our own refresh churn. + const shouldRefresh = mutations.some((mutation) => { + if (mutation.type !== 'childList') { + return false; + } + + const touchedThoughtNode = [...mutation.addedNodes, ...mutation.removedNodes].some((node) => { + if (!(node instanceof HTMLElement)) { + return false; + } + + return node.classList?.contains('mes') + || node.classList?.contains('mes_text') + || node.querySelector?.('.mes, .mes_text'); + }); + + return touchedThoughtNode; + }); + + if (!shouldRefresh) { + return; + } + + clearTimeout(inlineThoughtsRefreshTimeout); + inlineThoughtsRefreshTimeout = setTimeout(() => { + if (!extensionSettings.enabled + || !extensionSettings.showThoughtsInChat + || (extensionSettings.thoughtsInChatStyle || 'corner') !== 'inline') { + teardownInlineThoughtsObserver(); + return; + } + + isRefreshingInlineThoughts = true; + try { + updateChatThoughts(); + } finally { + isRefreshingInlineThoughts = false; + } + }, 50); + }); + + inlineThoughtsObserver.observe(chatElement, { + childList: true, + subtree: true + }); +} + +function teardownInlineThoughtsObserver() { + if (inlineThoughtsRefreshTimeout) { + clearTimeout(inlineThoughtsRefreshTimeout); + inlineThoughtsRefreshTimeout = null; + } + + if (inlineThoughtsObserver) { + inlineThoughtsObserver.disconnect(); + inlineThoughtsObserver = null; + } +} + +function parseThoughtsArray() { + let thoughtsArray = []; const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts; const thoughtsLabel = thoughtsConfig?.name || 'Thoughts'; - // Try JSON format first try { const parsed = typeof lastGeneratedData.characterThoughts === 'string' ? JSON.parse(lastGeneratedData.characterThoughts) : lastGeneratedData.characterThoughts; - // Handle both {characters: [...]} and direct array formats const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []); if (charactersArray.length > 0) { - // Extract thoughts from JSON character objects + const offScene = /\b(not\s+(currently\s+)?(in|at|present|in\s+the)\s+(the\s+)?(scene|area|room|location|vicinity))\b|\b(off[\s-]?scene)\b|\b(not\s+present)\b|\b(absent)\b|\b(away\s+from\s+(the\s+)?scene)\b/i; thoughtsArray = charactersArray - .filter(char => char.thoughts && char.thoughts.content) + .filter(char => char.thoughts && char.thoughts.content && !offScene.test(char.thoughts.content)) .map(char => ({ - name: (char.name || '').toLowerCase(), + name: (char.name || ''), emoji: char.emoji || '👤', thought: char.thoughts.content })); - - debugLog('[RPG Thoughts Bubble] ✓ Parsed JSON format, thoughts:', thoughtsArray.length); } } catch (e) { debugLog('[RPG Thoughts Bubble] Not JSON format, falling back to text parsing'); } - // If JSON parsing failed or returned empty, try text format - if (thoughtsArray.length === 0) { + if (thoughtsArray.length === 0 && lastGeneratedData.characterThoughts) { const lines = lastGeneratedData.characterThoughts.split('\n'); - - // console.log('[RPG Companion] Parsing thoughts from lines:', lines); - - // Parse new format to build character map and thoughts let currentCharName = null; let currentCharEmoji = null; @@ -1513,74 +1596,103 @@ export function updateChatThoughts() { continue; } - // Check if this is a character name line (starts with "- ") if (line.startsWith('- ')) { const name = line.substring(2).trim(); if (name && name.toLowerCase() !== 'unavailable') { currentCharName = name; - currentCharEmoji = null; // Reset emoji for new character + currentCharEmoji = null; } else { currentCharName = null; currentCharEmoji = null; } - } - // Check if this is a Details line (contains the emoji) - else if (line.startsWith('Details:') && currentCharName) { + } else if (line.startsWith('Details:') && currentCharName) { const detailsContent = line.substring(line.indexOf(':') + 1).trim(); const parts = detailsContent.split('|').map(p => p.trim()); - - // First part is the emoji if (parts.length > 0) { currentCharEmoji = parts[0]; } - } - // Check if this is a Thoughts line - else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) { + } else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) { const thoughtContent = line.substring(thoughtsLabel.length + 1).trim(); - - // The thought content is just the text (no emoji prefix in new format) if (thoughtContent) { thoughtsArray.push({ - name: currentCharName.toLowerCase(), + name: currentCharName, emoji: currentCharEmoji, thought: thoughtContent }); } } } - } // End of text format parsing for thoughts bubbles + } - debugLog('[RPG Thoughts] Parsed thoughts:', thoughtsArray); + return thoughtsArray; +} - // If no thoughts parsed, return - if (thoughtsArray.length === 0) { - // console.log('[RPG Companion] No thoughts parsed, returning'); +function escapeInlineThoughtHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function insertInlineThoughts($message, thoughtsArray, openThoughts = new Set()) { + const $mesText = $message.find('.mes_text'); + if (!$mesText.length) { return; } - // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); - // console.log('[RPG Companion] Thoughts array:', thoughtsArray); - - // Find the last message to position near - const $messages = $('#chat .mes'); - let $targetMessage = null; - - // Find the most recent non-user message - for (let i = $messages.length - 1; i >= 0; i--) { - const $message = $messages.eq(i); - if ($message.attr('is_user') !== 'true') { - $targetMessage = $message; - break; - } + const thoughtsMap = {}; + for (const thoughtData of thoughtsArray) { + thoughtsMap[(thoughtData.name || '').toLowerCase()] = thoughtData; } - if (!$targetMessage) { - // console.log('[RPG Companion] No target message found'); + const $container = $('
'); + bindInlineThoughtEvents($container); + + for (const [, thoughtData] of Object.entries(thoughtsMap)) { + const $dropdown = createInlineThoughtDropdown(thoughtData, openThoughts); + $container.append($dropdown); + } + + if (!$container.children().length) { return; } - // Create the thought panel with all thoughts - createThoughtPanel($targetMessage, thoughtsArray); + // Mount outside .mes_text so SillyTavern's click-to-edit handlers do not + // intercept summary clicks before the details element can toggle. + const $mediaWrapper = $message.find('.mes_media_wrapper').first(); + if ($mediaWrapper.length) { + $container.insertBefore($mediaWrapper); + } else { + $container.insertAfter($mesText.last()); + } +} + +function bindInlineThoughtEvents($container) { + $container.on('click mousedown touchstart', '.rpg-inline-thought, .rpg-inline-thought-summary, .rpg-inline-thought-content', function (e) { + e.stopPropagation(); + }); +} + +function createInlineThoughtDropdown(thoughtData, openThoughts = new Set()) { + const characterName = thoughtData.name || ''; + const characterEmoji = thoughtData.emoji || '👤'; + const thoughtText = thoughtData.thought || ''; + const normalizedCharacterName = characterName.toLowerCase(); + const openAttribute = openThoughts.has(normalizedCharacterName) ? ' open' : ''; + + return $(` +
+ + ${escapeInlineThoughtHtml(characterEmoji)} + ${escapeInlineThoughtHtml(characterName)}'s thoughts + +
+
${escapeInlineThoughtHtml(thoughtText)}
+
+
+ `); } // ===== GLOBAL DRAGGING SETUP FOR THOUGHT ICON (MOBILE ONLY) ===== @@ -2326,7 +2438,9 @@ export function createThoughtPanel($message, thoughtsArray) { // Update icon const newIcon = !currentlyLocked ? '🔒' : '🔓'; - const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked'; + const newTitle = !currentlyLocked + ? (i18n.getTranslation('thoughts.locked') || 'Locked') + : (i18n.getTranslation('thoughts.unlocked') || 'Unlocked'); $icon.text(newIcon); $icon.attr('title', newTitle); diff --git a/src/systems/ui/alternatePresentCharacters.js b/src/systems/ui/alternatePresentCharacters.js new file mode 100644 index 0000000..75c93fe --- /dev/null +++ b/src/systems/ui/alternatePresentCharacters.js @@ -0,0 +1,177 @@ +import { extensionSettings } from '../../core/state.js'; +import { i18n } from '../../core/i18n.js'; +import { getThoughtBasedExpressionPortraitForCharacter } from '../../utils/thoughtBasedExpressionPortraits.js'; +import { getSafeImageSrc } from '../../utils/imageUrls.js'; +import { + getPresentCharactersTrackerData, + parsePresentCharacters, + resolvePresentCharacterPortrait +} from '../../utils/presentCharacters.js'; + +const PANEL_ID = 'rpg-alt-present-characters'; + +function ensureAlternatePresentCharactersPanel() { + let $panel = $(`#${PANEL_ID}`); + if ($panel.length) { + return $panel; + } + + $panel = $(``); + + const $sendForm = $('#send_form'); + const $sheld = $('#sheld'); + const $chat = $sheld.find('#chat'); + + if ($sendForm.length) { + $sendForm.before($panel); + } else if ($chat.length) { + $chat.after($panel); + } else if ($sheld.length) { + $sheld.append($panel); + } else { + $('body').append($panel); + } + + return $panel; +} + +function hexToRgba(hex, opacity = 100) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const a = opacity / 100; + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +function handlePortraitLoadError() { + this.style.opacity = '0.5'; + $(this).off('error', handlePortraitLoadError); +} + +function createAlternatePresentCharacterCard(character) { + const rawPortrait = (extensionSettings.enableThoughtBasedExpressions + ? getThoughtBasedExpressionPortraitForCharacter(character.name) + : null) || resolvePresentCharacterPortrait(character.name); + const portrait = getSafeImageSrc(rawPortrait); + const name = String(character.name || ''); + + const $card = $('
') + .attr('data-character-name', name) + .attr('title', name); + + const $portrait = $('
'); + const $image = $('') + .attr({ + alt: name, + loading: 'lazy' + }) + .on('error', handlePortraitLoadError); + + if (portrait) { + $image.attr('src', portrait); + } + + const $meta = $('
'); + const $name = $('
').text(name); + + $portrait.append($image); + $meta.append($name); + $card.append($portrait, $meta); + + return $card; +} + +export function removeAlternatePresentCharactersPanel() { + $(`#${PANEL_ID}`).remove(); +} + +export function syncAlternatePresentCharactersTheme() { + const $panel = $(`#${PANEL_ID}`); + if (!$panel.length) { + return; + } + + const theme = extensionSettings.theme || 'default'; + + $panel.css({ + '--rpg-bg': '', + '--rpg-accent': '', + '--rpg-text': '', + '--rpg-highlight': '', + '--rpg-border': '', + '--rpg-shadow': '' + }); + + if (theme === 'default') { + $panel.removeAttr('data-theme'); + return; + } + + $panel.attr('data-theme', theme); + + if (theme === 'custom') { + const colors = extensionSettings.customColors || {}; + const bgColor = hexToRgba(colors.bg || '#1a1a2e', colors.bgOpacity ?? 100); + const accentColor = hexToRgba(colors.accent || '#16213e', colors.accentOpacity ?? 100); + const textColor = hexToRgba(colors.text || '#eaeaea', colors.textOpacity ?? 100); + const highlightColor = hexToRgba(colors.highlight || '#e94560', colors.highlightOpacity ?? 100); + const shadowColor = hexToRgba(colors.highlight || '#e94560', (colors.highlightOpacity ?? 100) * 0.5); + + $panel.css({ + '--rpg-bg': bgColor, + '--rpg-accent': accentColor, + '--rpg-text': textColor, + '--rpg-highlight': highlightColor, + '--rpg-border': highlightColor, + '--rpg-shadow': shadowColor + }); + } +} + +export function renderAlternatePresentCharacters({ useCommittedFallback = true } = {}) { + if (!extensionSettings.enabled || !extensionSettings.showAlternatePresentCharactersPanel) { + removeAlternatePresentCharactersPanel(); + return; + } + + const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback }); + if (!characterThoughtsData) { + const $panel = ensureAlternatePresentCharactersPanel(); + $panel.empty().hide(); + return; + } + + const presentCharacters = parsePresentCharacters(characterThoughtsData); + if (presentCharacters.length === 0) { + const $panel = ensureAlternatePresentCharactersPanel(); + $panel.empty().hide(); + return; + } + + const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters'; + + const $panel = ensureAlternatePresentCharactersPanel(); + const $header = $('
'); + const $headerTitle = $('
'); + const $scroll = $('
'); + const $track = $('
'); + + $headerTitle.append( + $(''), + $('').text(title) + ); + + $header.append( + $headerTitle, + $('
').text(String(presentCharacters.length)) + ); + + for (const character of presentCharacters) { + $track.append(createAlternatePresentCharacterCard(character)); + } + + $scroll.append($track); + + $panel.empty().append($header, $scroll).show(); + syncAlternatePresentCharactersTheme(); +} diff --git a/src/systems/ui/modals.js b/src/systems/ui/modals.js index be57170..2c9d577 100644 --- a/src/systems/ui/modals.js +++ b/src/systems/ui/modals.js @@ -11,6 +11,7 @@ import { $infoBoxContainer, $thoughtsContainer, $userStatsContainer, + clearThoughtBasedExpressionPortraits, setPendingDiceRoll, getPendingDiceRoll, clearSessionAvatarPrompts @@ -370,6 +371,7 @@ export function setupSettingsPopup() { // Clear session avatar prompts clearSessionAvatarPrompts(); + clearThoughtBasedExpressionPortraits(); // Clear chat metadata immediately (don't wait for debounced save) const context = getContext(); @@ -387,6 +389,14 @@ export function setupSettingsPopup() { delete message.extra.rpg_companion_swipes; // console.log('[RPG Companion] Cleared swipe data from message at index', i); } + + if (Array.isArray(message.swipe_info)) { + for (const swipeInfo of message.swipe_info) { + if (swipeInfo?.extra?.rpg_companion_swipes) { + delete swipeInfo.extra.rpg_companion_swipes; + } + } + } } } diff --git a/src/systems/ui/theme.js b/src/systems/ui/theme.js index 0bac819..61de7a4 100644 --- a/src/systems/ui/theme.js +++ b/src/systems/ui/theme.js @@ -4,6 +4,7 @@ */ import { extensionSettings, $panelContainer } from '../../core/state.js'; +import { syncAlternatePresentCharactersTheme } from './alternatePresentCharacters.js'; /** * Converts hex color and opacity percentage to rgba string @@ -96,6 +97,8 @@ export function applyTheme() { $thoughtPanel.attr('data-theme', theme); } } + + syncAlternatePresentCharactersTheme(); } /** @@ -150,6 +153,8 @@ export function applyCustomTheme() { if ($thoughtPanel.length) { $thoughtPanel.attr('data-theme', 'custom').css(customStyles); } + + syncAlternatePresentCharactersTheme(); } /** diff --git a/src/utils/imageUrls.js b/src/utils/imageUrls.js new file mode 100644 index 0000000..df0a0aa --- /dev/null +++ b/src/utils/imageUrls.js @@ -0,0 +1,53 @@ +/** + * Image URL Utilities Module + * Centralizes validation for image sources captured from DOM or settings. + */ + +const DEFAULT_IMAGE_BASE_URL = typeof window !== 'undefined' + ? window.location.href + : 'http://localhost/'; + +export function normalizeImageSrc(src) { + return String(src ?? '').trim(); +} + +export function resolveImageUrl(src, baseUrl = DEFAULT_IMAGE_BASE_URL) { + const normalized = normalizeImageSrc(src); + if (!normalized) { + return null; + } + + try { + return new URL(normalized, baseUrl); + } catch { + return null; + } +} + +export function isSafeImageSrc(src) { + const normalized = normalizeImageSrc(src); + if (!normalized) { + return false; + } + + const candidate = resolveImageUrl(normalized); + if (!candidate) { + return false; + } + + const protocol = candidate.protocol.toLowerCase(); + if (protocol === 'http:' || protocol === 'https:' || protocol === 'blob:') { + return true; + } + + if (protocol === 'data:') { + return normalized.toLowerCase().startsWith('data:image/'); + } + + return false; +} + +export function getSafeImageSrc(src) { + const normalized = normalizeImageSrc(src); + return isSafeImageSrc(normalized) ? normalized : null; +} diff --git a/src/utils/presentCharacters.js b/src/utils/presentCharacters.js new file mode 100644 index 0000000..d70399b --- /dev/null +++ b/src/utils/presentCharacters.js @@ -0,0 +1,244 @@ +import { this_chid, characters } from '../../../../../../script.js'; +import { selected_group, getGroupMembers } from '../../../../../group-chats.js'; +import { + extensionSettings, + lastGeneratedData, + committedTrackerData, + FALLBACK_AVATAR_DATA_URI +} from '../core/state.js'; +import { getSafeThumbnailUrl } from './avatars.js'; + +export function stripBrackets(value) { + if (typeof value !== 'string') return value; + return value.replace(/^\[|\]$/g, '').trim(); +} + +export function extractFieldValue(fieldValue) { + if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) { + return fieldValue.value || ''; + } + return fieldValue || ''; +} + +export function toSnakeCase(name) { + return name + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '_') + .replace(/^_+|_+$/g, ''); +} + +export function namesMatch(cardName, aiName) { + if (!cardName || !aiName) return false; + + if (cardName.toLowerCase() === aiName.toLowerCase()) return true; + + const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim(); + const cardCore = stripParens(cardName).toLowerCase(); + const aiCore = stripParens(aiName).toLowerCase(); + if (cardCore === aiCore) return true; + + const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`); + return wordBoundary.test(aiCore); +} + +export function isPresentCharactersEnabled() { + return !!( + extensionSettings.showCharacterThoughts + || extensionSettings.showAlternatePresentCharactersPanel + || extensionSettings.showThoughtsInChat + ); +} + +export function getPresentCharactersTrackerData({ useCommittedFallback = true } = {}) { + return lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || ''; +} + +export function parsePresentCharacters(characterThoughtsData, { enabledFields = [], enabledCharStats = [] } = {}) { + if (!characterThoughtsData) { + return []; + } + + let presentCharacters = []; + + try { + const parsed = typeof characterThoughtsData === 'string' + ? JSON.parse(characterThoughtsData) + : characterThoughtsData; + + const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []); + + if (charactersArray.length > 0) { + presentCharacters = charactersArray.map(char => { + const character = { + name: char.name, + emoji: char.emoji || '👤' + }; + + if (char.details) { + for (const field of enabledFields) { + if (char.details[field.name] !== undefined) { + character[field.name] = stripBrackets(char.details[field.name]); + } else { + const fieldKey = toSnakeCase(field.name); + if (char.details[fieldKey] !== undefined) { + character[field.name] = stripBrackets(char.details[fieldKey]); + } + } + } + } + + for (const field of enabledFields) { + if (character[field.name] === undefined) { + const fieldKey = toSnakeCase(field.name); + if (char[fieldKey] !== undefined) { + character[field.name] = stripBrackets(char[fieldKey]); + } + } + } + + if (char.Relationship) { + character.Relationship = stripBrackets(char.Relationship); + } else if (char.relationship) { + character.Relationship = stripBrackets(char.relationship.status || char.relationship); + } + + if (char.thoughts) { + character.ThoughtsContent = stripBrackets(char.thoughts.content || char.thoughts); + } + + if (char.stats && enabledCharStats.length > 0) { + if (Array.isArray(char.stats)) { + for (const statObj of char.stats) { + if (statObj.name && statObj.value !== undefined) { + const matchingStat = enabledCharStats.find(s => s.name === statObj.name); + if (matchingStat) { + character[statObj.name] = statObj.value; + } + } + } + } else { + for (const stat of enabledCharStats) { + if (char.stats[stat.name] !== undefined) { + character[stat.name] = char.stats[stat.name]; + } + } + } + } + + return character; + }); + } + } catch { + // Fall back to the legacy text format below. + } + + if (presentCharacters.length > 0 || typeof characterThoughtsData !== 'string') { + return presentCharacters; + } + + const lines = characterThoughtsData.split('\n'); + let currentCharacter = null; + const thoughtsLabel = extensionSettings.trackerConfig?.presentCharacters?.thoughts?.name || 'Thoughts'; + + for (const line of lines) { + if (!line.trim() + || line.includes('Present Characters') + || line.includes('---') + || line.trim().startsWith('```') + || line.trim() === '- …' + || line.includes('(Repeat the format')) { + continue; + } + + if (line.trim().startsWith('- ')) { + const name = line.trim().substring(2).trim(); + + if (name && name.toLowerCase() !== 'unavailable') { + currentCharacter = { name }; + presentCharacters.push(currentCharacter); + } else { + currentCharacter = null; + } + } else if (line.trim().startsWith('Details:') && currentCharacter) { + const detailsContent = line.substring(line.indexOf(':') + 1).trim(); + const parts = detailsContent.split('|').map(p => p.trim()); + + if (parts.length > 0) { + currentCharacter.emoji = parts[0]; + } + + for (let i = 0; i < enabledFields.length && i + 1 < parts.length; i++) { + currentCharacter[enabledFields[i].name] = parts[i + 1]; + } + } else if (line.trim().startsWith('Relationship:') && currentCharacter) { + currentCharacter.Relationship = line.substring(line.indexOf(':') + 1).trim(); + } else if (line.trim().startsWith('Stats:') && currentCharacter && enabledCharStats.length > 0) { + const statsContent = line.substring(line.indexOf(':') + 1).trim(); + const statParts = statsContent.split('|').map(p => p.trim()); + + for (const statPart of statParts) { + const statMatch = statPart.match(/^(.+?):\s*(\d+)%$/); + if (statMatch) { + currentCharacter[statMatch[1].trim()] = parseInt(statMatch[2], 10); + } + } + } else if (line.trim().startsWith(thoughtsLabel + ':') && currentCharacter) { + currentCharacter.ThoughtsContent = line.substring(line.indexOf(':') + 1).trim(); + } + } + + return presentCharacters; +} + +export function resolvePresentCharacterPortrait(name) { + let characterPortrait = FALLBACK_AVATAR_DATA_URI; + + if (!name) { + return characterPortrait; + } + + if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[name]) { + return extensionSettings.npcAvatars[name]; + } + + if (selected_group) { + try { + const groupMembers = getGroupMembers(selected_group); + const matchingMember = groupMembers?.find(member => + member && member.name && namesMatch(member.name, name) + ); + + if (matchingMember?.avatar && matchingMember.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); + if (thumbnailUrl) { + return thumbnailUrl; + } + } + } catch { + // Ignore avatar lookup issues and continue through fallback chain. + } + } + + if (characters?.length > 0) { + const matchingCharacter = characters.find(character => + character && character.name && namesMatch(character.name, name) + ); + + if (matchingCharacter?.avatar && matchingCharacter.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); + if (thumbnailUrl) { + return thumbnailUrl; + } + } + } + + if (this_chid !== undefined && characters?.[this_chid]?.name && namesMatch(characters[this_chid].name, name)) { + const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); + if (thumbnailUrl) { + return thumbnailUrl; + } + } + + return characterPortrait; +} diff --git a/src/utils/sillyTavernExpressions.js b/src/utils/sillyTavernExpressions.js new file mode 100644 index 0000000..991975c --- /dev/null +++ b/src/utils/sillyTavernExpressions.js @@ -0,0 +1,626 @@ +import { Fuse } from '../../../../../../lib.js'; +import { + characters, + eventSource, + event_types, + generateQuietPrompt, + generateRaw, + getRequestHeaders, + online_status, + substituteParams, + substituteParamsExtended, + this_chid +} from '../../../../../../script.js'; +import { + doExtrasFetch, + extension_settings as stExtensionSettings, + getApiUrl, + modules +} from '../../../../../extensions.js'; +import { selected_group, getGroupMembers } from '../../../../../group-chats.js'; +import { removeReasoningFromString } from '../../../../../reasoning.js'; +import { isJsonSchemaSupported } from '../../../../../textgen-settings.js'; +import { trimToEndSentence, trimToStartSentence, waitUntilCondition } from '../../../../../utils.js'; +import { generateWebLlmChatPrompt, isWebLlmSupported } from '../../../../../extensions/shared.js'; +import { namesMatch } from './presentCharacters.js'; +import { normalizeImageSrc } from './imageUrls.js'; + +const EXPRESSIONS_EXTENSION_NAME = 'expressions'; +const DEFAULT_FALLBACK_EXPRESSION = 'joy'; +const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}'; +const DEFAULT_EXPRESSIONS = [ + 'admiration', + 'amusement', + 'anger', + 'annoyance', + 'approval', + 'caring', + 'confusion', + 'curiosity', + 'desire', + 'disappointment', + 'disapproval', + 'disgust', + 'embarrassment', + 'excitement', + 'fear', + 'gratitude', + 'grief', + 'joy', + 'love', + 'nervousness', + 'optimism', + 'pride', + 'realization', + 'relief', + 'remorse', + 'sadness', + 'surprise', + 'neutral' +]; + +export const EXPRESSION_API = { + local: 0, + extras: 1, + llm: 2, + webllm: 3, + none: 99 +}; + +const PROMPT_TYPE = { + raw: 'raw', + full: 'full' +}; + +let expressionsListCache = null; +const spriteCache = new Map(); + +function getNormalizedExpressionsSettings() { + const settings = stExtensionSettings.expressions || {}; + + return { + api: Number.isInteger(settings.api) ? settings.api : EXPRESSION_API.none, + custom: Array.isArray(settings.custom) ? settings.custom.slice() : [], + showDefault: settings.showDefault === true, + translate: settings.translate === true, + fallbackExpression: typeof settings.fallback_expression === 'string' && settings.fallback_expression.trim() + ? settings.fallback_expression.trim().toLowerCase() + : '', + llmPrompt: typeof settings.llmPrompt === 'string' && settings.llmPrompt.trim() + ? settings.llmPrompt + : DEFAULT_LLM_PROMPT, + allowMultiple: settings.allowMultiple !== false, + rerollIfSame: settings.rerollIfSame === true, + filterAvailable: settings.filterAvailable === true, + promptType: settings.promptType === PROMPT_TYPE.full ? PROMPT_TYPE.full : PROMPT_TYPE.raw, + expressionOverrides: Array.isArray(stExtensionSettings.expressionOverrides) + ? stExtensionSettings.expressionOverrides.slice() + : [] + }; +} + +export function isExpressionsExtensionEnabled() { + return !stExtensionSettings.disabledExtensions?.includes(EXPRESSIONS_EXTENSION_NAME); +} + +export function getExpressionsSettingsSignature() { + if (!isExpressionsExtensionEnabled()) { + return 'disabled'; + } + + const settings = getNormalizedExpressionsSettings(); + return JSON.stringify({ + api: settings.api, + custom: settings.custom, + showDefault: settings.showDefault, + translate: settings.translate, + fallbackExpression: settings.fallbackExpression, + llmPrompt: settings.llmPrompt, + allowMultiple: settings.allowMultiple, + rerollIfSame: settings.rerollIfSame, + filterAvailable: settings.filterAvailable, + promptType: settings.promptType, + expressionOverrides: settings.expressionOverrides + }); +} + +export function getExpressionClassificationSettingsSignature() { + if (!isExpressionsExtensionEnabled()) { + return 'disabled'; + } + + const settings = getNormalizedExpressionsSettings(); + return JSON.stringify({ + api: settings.api, + custom: settings.custom, + translate: settings.translate, + fallbackExpression: settings.fallbackExpression, + llmPrompt: settings.llmPrompt, + filterAvailable: settings.filterAvailable, + promptType: settings.promptType + }); +} + +export function getExpressionPortraitSettingsSignature() { + if (!isExpressionsExtensionEnabled()) { + return 'disabled'; + } + + const settings = getNormalizedExpressionsSettings(); + return JSON.stringify({ + custom: settings.custom, + showDefault: settings.showDefault, + fallbackExpression: settings.fallbackExpression, + allowMultiple: settings.allowMultiple, + rerollIfSame: settings.rerollIfSame + }); +} + +export function clearExpressionsCompatibilityCache() { + expressionsListCache = null; + spriteCache.clear(); +} + +function uniqueValues(values) { + return values.filter((value, index) => values.indexOf(value) === index); +} + +function normalizeExpressionLabel(label) { + return String(label || '').trim().toLowerCase(); +} + +function stripExtension(fileName) { + return String(fileName || '').replace(/\.[^/.]+$/, ''); +} + +function resolveFolderOverride(folderName, expressionOverrides) { + const override = expressionOverrides.find(entry => entry?.name === folderName); + return override?.path ? String(override.path) : folderName; +} + +function getAvatarFolderName(avatar) { + if (!avatar || avatar === 'none') { + return ''; + } + + return String(avatar).replace(/\.[^/.]+$/, ''); +} + +export function resolveSpriteFolderNameForCharacter(characterName) { + if (!characterName) { + return ''; + } + + const settings = getNormalizedExpressionsSettings(); + const groupId = selected_group; + + if (groupId) { + try { + const groupMembers = getGroupMembers(groupId) || []; + const matchingMember = groupMembers.find(member => + member?.name && namesMatch(member.name, characterName)); + + const memberFolder = getAvatarFolderName(matchingMember?.avatar); + if (memberFolder) { + return resolveFolderOverride(memberFolder, settings.expressionOverrides); + } + } catch { + // Ignore group lookup issues and continue through the fallback chain. + } + } + + if (Array.isArray(characters) && characters.length > 0) { + const matchingCharacter = characters.find(character => + character?.name && namesMatch(character.name, characterName)); + + const characterFolder = getAvatarFolderName(matchingCharacter?.avatar); + if (characterFolder) { + return resolveFolderOverride(characterFolder, settings.expressionOverrides); + } + } + + if (this_chid !== undefined && characters?.[this_chid]?.name && namesMatch(characters[this_chid].name, characterName)) { + const currentCharacterFolder = getAvatarFolderName(characters[this_chid].avatar); + if (currentCharacterFolder) { + return resolveFolderOverride(currentCharacterFolder, settings.expressionOverrides); + } + } + + return ''; +} + +function sampleClassifyText(text, expressionsApi) { + if (!text) { + return ''; + } + + let result = substituteParams(text).replace(/[*"]/g, ''); + + if (expressionsApi === EXPRESSION_API.llm) { + return result.trim(); + } + + const SAMPLE_THRESHOLD = 500; + const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2; + + if (text.length < SAMPLE_THRESHOLD) { + result = trimToEndSentence(result); + } else { + result = `${trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD))} ${trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD))}`; + } + + return result.trim(); +} + +function getJsonSchema(labels) { + return { + $schema: 'http://json-schema.org/draft-04/schema#', + type: 'object', + properties: { + emotion: { + type: 'string', + enum: labels + } + }, + required: ['emotion'], + additionalProperties: false + }; +} + +function buildFullContextThoughtPrompt(prompt, text) { + return [ + prompt, + '', + 'Classify the emotion of the following text instead of the last chat message.', + 'Output exactly one label from the allowed list.', + '', + `Text: ${text}` + ].join('\n'); +} + +function parseLlmResponse(emotionResponse, labels) { + try { + const parsedEmotion = JSON.parse(emotionResponse); + const response = parsedEmotion?.emotion?.trim()?.toLowerCase(); + + if (response && labels.includes(response)) { + return response; + } + } catch { + // Fall through to the fuzzy parse below. + } + + const cleanedResponse = removeReasoningFromString(String(emotionResponse || '')); + const lowerCaseResponse = cleanedResponse.toLowerCase(); + + for (const label of labels) { + if (lowerCaseResponse.includes(label.toLowerCase())) { + return label; + } + } + + const fuse = new Fuse(labels, { includeScore: true }); + const match = fuse.search(cleanedResponse)[0]; + if (match?.item) { + return match.item; + } + + throw new Error('Could not parse expression label from response'); +} + +async function resolveExpressionsList() { + const settings = getNormalizedExpressionsSettings(); + + try { + if (settings.api === EXPRESSION_API.extras && modules.includes('classify')) { + const url = new URL(getApiUrl()); + url.pathname = '/api/classify/labels'; + + const response = await doExtrasFetch(url, { + method: 'GET', + headers: { 'Bypass-Tunnel-Reminder': 'bypass' } + }); + + if (response.ok) { + const data = await response.json(); + return Array.isArray(data?.labels) + ? data.labels.map(normalizeExpressionLabel).filter(Boolean) + : DEFAULT_EXPRESSIONS.slice(); + } + } + + if (settings.api === EXPRESSION_API.local) { + const response = await fetch('/api/extra/classify/labels', { + method: 'POST', + headers: getRequestHeaders({ omitContentType: true }) + }); + + if (response.ok) { + const data = await response.json(); + return Array.isArray(data?.labels) + ? data.labels.map(normalizeExpressionLabel).filter(Boolean) + : DEFAULT_EXPRESSIONS.slice(); + } + } + } catch { + // Fall back to the built-in labels below. + } + + return DEFAULT_EXPRESSIONS.slice(); +} + +async function getAvailableExpressionLabelsForCharacter(characterName) { + const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName); + if (!spriteFolderName) { + return []; + } + + const expressions = await getSpritesList(spriteFolderName); + return expressions + .filter(expression => Array.isArray(expression?.files) && expression.files.length > 0) + .map(expression => String(expression.label || '').trim().toLowerCase()) + .filter(Boolean); +} + +export async function getExpressionsList({ characterName = '', filterAvailable = false } = {}) { + if (!Array.isArray(expressionsListCache)) { + expressionsListCache = await resolveExpressionsList(); + } + + const settings = getNormalizedExpressionsSettings(); + const expressions = uniqueValues([...expressionsListCache, ...settings.custom.map(value => String(value).trim().toLowerCase())]) + .filter(Boolean); + + if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(settings.api)) { + return expressions; + } + + const availableExpressions = await getAvailableExpressionLabelsForCharacter(characterName); + if (!availableExpressions.length) { + return expressions; + } + + return expressions.filter(expression => availableExpressions.includes(expression)); +} + +async function getSpritesList(spriteFolderName) { + if (!spriteFolderName) { + return []; + } + + if (spriteCache.has(spriteFolderName)) { + return spriteCache.get(spriteFolderName); + } + + try { + const response = await fetch(`/api/sprites/get?name=${encodeURIComponent(spriteFolderName)}`); + const sprites = response.ok ? await response.json() : []; + const grouped = []; + + for (const sprite of Array.isArray(sprites) ? sprites : []) { + const fileName = String(sprite?.path || '').split('/').pop()?.split('?')[0] || ''; + const imageData = { + expression: normalizeExpressionLabel(sprite?.label), + fileName, + title: stripExtension(fileName), + imageSrc: String(sprite?.path || ''), + type: 'success', + isCustom: getNormalizedExpressionsSettings().custom.includes(normalizeExpressionLabel(sprite?.label)) + }; + + let existing = grouped.find(entry => entry.label === imageData.expression); + if (!existing) { + existing = { label: imageData.expression, files: [] }; + grouped.push(existing); + } + + existing.files.push(imageData); + } + + for (const expression of grouped) { + expression.files.sort((left, right) => { + if (left.title === expression.label) return -1; + if (right.title === expression.label) return 1; + return left.title.localeCompare(right.title); + }); + } + + spriteCache.set(spriteFolderName, grouped); + return grouped; + } catch { + spriteCache.set(spriteFolderName, []); + return []; + } +} + +function chooseSpriteForExpression(expressions, expression, { previousSrc = null } = {}) { + const settings = getNormalizedExpressionsSettings(); + let sprite = expressions.find(entry => entry.label === expression); + + if (!(sprite?.files?.length > 0) && settings.fallbackExpression) { + sprite = expressions.find(entry => entry.label === settings.fallbackExpression); + } + + if (!(sprite?.files?.length > 0)) { + return null; + } + + let candidates = sprite.files; + if (settings.allowMultiple && sprite.files.length > 1) { + if (settings.rerollIfSame) { + const filtered = sprite.files.filter(file => !previousSrc || file.imageSrc !== previousSrc); + if (filtered.length > 0) { + candidates = filtered; + } + } + + return candidates[Math.floor(Math.random() * candidates.length)] || null; + } + + return candidates[0] || null; +} + +function getDefaultExpressionImage(expression, customExpressions) { + let normalizedExpression = String(expression || '').trim().toLowerCase(); + + if (!normalizedExpression) { + return ''; + } + + if (customExpressions.includes(normalizedExpression)) { + normalizedExpression = DEFAULT_FALLBACK_EXPRESSION; + } + + return `/img/default-expressions/${normalizedExpression}.png`; +} + +export async function classifyExpressionText(text, { characterName = '' } = {}) { + if (!isExpressionsExtensionEnabled()) { + return null; + } + + const settings = getNormalizedExpressionsSettings(); + if (!text) { + return settings.fallbackExpression || ''; + } + + if (settings.api === EXPRESSION_API.none) { + return settings.fallbackExpression || ''; + } + + let processedText = text; + if (settings.translate && typeof globalThis.translate === 'function') { + processedText = await globalThis.translate(processedText, 'en'); + } + + processedText = sampleClassifyText(processedText, settings.api); + if (!processedText) { + return settings.fallbackExpression || ''; + } + + const labels = await getExpressionsList({ + characterName, + filterAvailable: settings.filterAvailable === true + }); + const fallbackLabels = labels.length > 0 ? labels : await getExpressionsList(); + + try { + switch (settings.api) { + case EXPRESSION_API.local: { + const response = await fetch('/api/extra/classify', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ text: processedText }) + }); + + if (response.ok) { + const data = await response.json(); + return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase(); + } + break; + } + case EXPRESSION_API.extras: { + if (!modules.includes('classify')) { + return settings.fallbackExpression || ''; + } + + const url = new URL(getApiUrl()); + url.pathname = '/api/classify'; + + const response = await doExtrasFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Bypass-Tunnel-Reminder': 'bypass' + }, + body: JSON.stringify({ text: processedText }) + }); + + if (response.ok) { + const data = await response.json(); + return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase(); + } + break; + } + case EXPRESSION_API.llm: { + await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); + + const labelsString = fallbackLabels.map(label => `"${label}"`).join(', '); + const basePrompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString }); + const prompt = settings.promptType === PROMPT_TYPE.full + ? buildFullContextThoughtPrompt(basePrompt, processedText) + : basePrompt; + const onReady = (args) => { + if (isJsonSchemaSupported()) { + Object.assign(args, { + top_k: 1, + stop: [], + stopping_strings: [], + custom_token_bans: [], + json_schema: getJsonSchema(fallbackLabels) + }); + } + }; + + eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onReady); + + const responseText = settings.promptType === PROMPT_TYPE.full + ? await generateQuietPrompt({ quietPrompt: prompt }) + : await generateRaw({ prompt: processedText, systemPrompt: prompt }); + + return parseLlmResponse(responseText, fallbackLabels); + } + case EXPRESSION_API.webllm: { + if (!isWebLlmSupported()) { + return settings.fallbackExpression || ''; + } + + const labelsString = fallbackLabels.map(label => `"${label}"`).join(', '); + const prompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString }); + const responseText = await generateWebLlmChatPrompt([ + { + role: 'user', + content: `${processedText}\n\n${prompt}` + } + ]); + + return parseLlmResponse(responseText, fallbackLabels); + } + default: + break; + } + } catch { + return settings.fallbackExpression || ''; + } + + return settings.fallbackExpression || ''; +} + +export async function resolveExpressionPortraitForCharacter(characterName, expression, { previousSrc = null } = {}) { + if (!isExpressionsExtensionEnabled()) { + return null; + } + + const settings = getNormalizedExpressionsSettings(); + const normalizedExpression = String(expression || '').trim().toLowerCase(); + const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName); + + if (spriteFolderName) { + const expressions = await getSpritesList(spriteFolderName); + const spriteFile = chooseSpriteForExpression(expressions, normalizedExpression, { previousSrc }); + const spriteSrc = normalizeImageSrc(spriteFile?.imageSrc || ''); + if (spriteSrc) { + return spriteSrc; + } + } + + if (settings.showDefault) { + const defaultExpression = normalizedExpression || settings.fallbackExpression; + const defaultImage = normalizeImageSrc(getDefaultExpressionImage(defaultExpression, settings.custom)); + if (defaultImage) { + return defaultImage; + } + } + + return null; +} diff --git a/src/utils/thoughtBasedExpressionPortraits.js b/src/utils/thoughtBasedExpressionPortraits.js new file mode 100644 index 0000000..e85158c --- /dev/null +++ b/src/utils/thoughtBasedExpressionPortraits.js @@ -0,0 +1,73 @@ +import { + thoughtBasedExpressionPortraits, + getThoughtBasedExpressionPortrait +} from '../core/state.js'; +import { + isSafeImageSrc, + normalizeImageSrc, + resolveImageUrl +} from './imageUrls.js'; +import { isExpressionsExtensionEnabled } from './sillyTavernExpressions.js'; + +function normalizeName(name) { + return String(name || '').trim().toLowerCase(); +} + +function namesMatch(a, b) { + const left = normalizeName(a); + const right = normalizeName(b); + if (!left || !right) { + return false; + } + + return left === right || left.startsWith(right + ' ') || right.startsWith(left + ' '); +} + +function isDocumentLikeUrl(src) { + const candidate = resolveImageUrl(src); + if (!candidate) { + return false; + } + + const current = new URL(window.location.href); + return candidate.origin === current.origin + && candidate.pathname === current.pathname + && candidate.search === current.search; +} + +export function isUsableThoughtBasedExpressionSrc(src) { + const normalized = normalizeImageSrc(src); + if (!normalized) { + return false; + } + + if (isDocumentLikeUrl(normalized)) { + return false; + } + + return isSafeImageSrc(normalized); +} + +export function getThoughtBasedExpressionPortraitForCharacter(characterName) { + if (!isExpressionsExtensionEnabled()) { + return null; + } + + const target = normalizeName(characterName); + if (!target) { + return null; + } + + const exact = getThoughtBasedExpressionPortrait(target); + if (isUsableThoughtBasedExpressionSrc(exact)) { + return exact; + } + + for (const [storedName, src] of Object.entries(thoughtBasedExpressionPortraits)) { + if (namesMatch(storedName, target) && isUsableThoughtBasedExpressionSrc(src)) { + return src; + } + } + + return null; +} diff --git a/style.css b/style.css index 802054c..55b6256 100644 --- a/style.css +++ b/style.css @@ -15,6 +15,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-panel, #rpg-thought-panel, #rpg-thought-icon, +#rpg-alt-present-characters, .rpg-mobile-toggle { --rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9)); --rpg-accent: var(--black30a, rgba(22, 33, 62, 0.9)); @@ -3256,6 +3257,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Apply sci-fi theme to thought panel */ #rpg-thought-panel[data-theme="sci-fi"], #rpg-thought-icon[data-theme="sci-fi"], +#rpg-alt-present-characters[data-theme="sci-fi"], .rpg-mobile-toggle[data-theme="sci-fi"] { --rpg-bg: #0a0e27; --rpg-accent: #1a1f3a; @@ -3304,6 +3306,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Apply fantasy theme to thought panel */ #rpg-thought-panel[data-theme="fantasy"], #rpg-thought-icon[data-theme="fantasy"], +#rpg-alt-present-characters[data-theme="fantasy"], .rpg-mobile-toggle[data-theme="fantasy"] { --rpg-bg: #2b1810; --rpg-accent: #3d2414; @@ -3361,6 +3364,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Apply cyberpunk theme to thought panel */ #rpg-thought-panel[data-theme="cyberpunk"], #rpg-thought-icon[data-theme="cyberpunk"], +#rpg-alt-present-characters[data-theme="cyberpunk"], .rpg-mobile-toggle[data-theme="cyberpunk"] { --rpg-bg: #000000; --rpg-accent: #0d0d0d; @@ -5025,6 +5029,159 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } } +/* ============================================ + BELOW-CHAT PRESENT CHARACTERS + ============================================ */ + +#rpg-alt-present-characters { + margin: 0 0 10px; + padding: 8px 10px 8px; + border-radius: 14px; + border: 1px solid var(--rpg-border, rgba(255, 255, 255, 0.14)); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0) 100%), + linear-gradient(135deg, var(--rpg-accent, rgba(34, 40, 60, 0.94)) 0%, var(--rpg-bg, rgba(18, 21, 34, 0.96)) 100%); + box-shadow: 0 12px 28px var(--rpg-shadow, rgba(0, 0, 0, 0.24)); + color: var(--rpg-text, var(--SmartThemeBodyColor, #ecf0f1)); + backdrop-filter: blur(12px); +} + +.rpg-alt-present-characters__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.rpg-alt-present-characters__title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.rpg-alt-present-characters__title i { + color: var(--rpg-highlight, #e94560); +} + +.rpg-alt-present-characters__count { + min-width: 24px; + height: 24px; + padding: 0 7px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--rpg-border, rgba(255, 255, 255, 0.14)); + color: var(--rpg-text, var(--SmartThemeBodyColor, #ecf0f1)); + font-size: 0.74rem; + font-weight: 700; +} + +.rpg-alt-present-characters__scroll { + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 2px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.24) transparent; + transform: scaleY(-1); +} + +.rpg-alt-present-characters__scroll::-webkit-scrollbar { + height: 6px; +} + +.rpg-alt-present-characters__scroll::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-alt-present-characters__scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.24); + border-radius: 999px; +} + +.rpg-alt-present-characters__scroll::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.34); +} + +.rpg-alt-present-characters__track { + display: flex; + gap: 10px; + width: max-content; + min-width: 100%; + transform: scaleY(-1); + padding-top: 8px; + padding-bottom: 2px; +} + +.rpg-alt-present-character { + flex: 0 0 98px; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 98px; +} + +.rpg-alt-present-character__portrait { + position: relative; + aspect-ratio: 11 / 15; + border-radius: 12px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.14); + background: var(--rpg-bg, rgba(18, 21, 34, 0.96)); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18); + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.rpg-alt-present-character__portrait:hover { + transform: translateY(-2px); + border-color: var(--rpg-highlight, #e94560); + box-shadow: 0 12px 20px rgba(0, 0, 0, 0.22); +} + +.rpg-alt-present-character__portrait img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.rpg-alt-present-character__meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.rpg-alt-present-character__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-alt-present-character__name { + font-size: 0.78rem; + font-weight: 600; + line-height: 1.1; + text-align: center; +} + +@media (max-width: 768px) { + #rpg-alt-present-characters { + margin-bottom: 8px; + padding: 7px 9px 7px; + border-radius: 12px; + } + + .rpg-alt-present-character { + flex-basis: 84px; + min-width: 84px; + } +} + /* ============================================ CHAT THOUGHT OVERLAYS ============================================ */ @@ -11684,3 +11841,70 @@ body:has(.rpg-panel[data-theme="light"]) .rpg-strip-widget { min-width: 2.5rem !important; } } + +.rpg-inline-thoughts { + display: flex; + flex-direction: column; + gap: 0.5em; + margin: 0.2em var(--mes-right-spacing, 30px) 0.35em 0; +} + +body.documentstyle .rpg-inline-thoughts { + margin-left: 20px; +} + +.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; +} + diff --git a/template.html b/template.html index 4201819..b51c991 100644 --- a/template.html +++ b/template.html @@ -358,6 +358,33 @@ Display character portraits with their current thoughts and status. + + + Display a compact Present Characters panel below the chat. + + + + + 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. + + + + + Hide SillyTavern's built-in Character Expressions display. + +