Alternate Thoughts Display (#2)
This PR adds an optional alternate display mode for RPG Companion thoughts. When enabled, thoughts are shown as compact expandable cards directly below the relevant latest character message. When disabled, RPG Companion keeps its original corner/overlay thought bubbles, so the existing behavior is preserved unless the user explicitly switches modes. The new display mode is built on top of RPG Companion’s existing thoughts system rather than replacing it. Thought UI now updates more reliably across new generations, swipe changes, message deletion, chat reload/re-entry, and live mode toggling, so thoughts stay attached to the correct visible message instead of lingering on stale UI. It also improves restoration of RPG Companion state after reopening a chat, making thoughts and related tracker data more consistent with the current chat view.
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
||||
setLastActionWasSwipe,
|
||||
$musicPlayerContainer
|
||||
} from '../../core/state.js';
|
||||
import { saveChatData } from '../../core/persistence.js';
|
||||
import { saveChatData, setMessageSwipeTrackerData } from '../../core/persistence.js';
|
||||
import {
|
||||
generateSeparateUpdatePrompt
|
||||
} from './promptBuilder.js';
|
||||
@@ -317,11 +317,11 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
|
||||
}
|
||||
|
||||
const currentSwipeId = lastMessage.swipe_id || 0;
|
||||
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
|
||||
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
|
||||
userStats: parsedData.userStats,
|
||||
infoBox: parsedData.infoBox,
|
||||
characterThoughts: parsedData.characterThoughts
|
||||
};
|
||||
});
|
||||
|
||||
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getContext } from '../../../../../../extensions.js';
|
||||
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, saveChatDebounced } from '../../../../../../../script.js';
|
||||
import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js';
|
||||
|
||||
// Core modules
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
updateCommittedTrackerData,
|
||||
$musicPlayerContainer
|
||||
} from '../../core/state.js';
|
||||
import { saveChatData, loadChatData, autoSwitchPresetForEntity } from '../../core/persistence.js';
|
||||
import { saveChatData, loadChatData, autoSwitchPresetForEntity, getMessageSwipeTrackerData, getCurrentMessageSwipeTrackerData, restoreLatestTrackerStateFromChat, setMessageSwipeTrackerData } from '../../core/persistence.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
|
||||
// Generation & Parsing
|
||||
@@ -51,6 +51,8 @@ import { updateStripWidgets } from '../ui/desktop.js';
|
||||
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
|
||||
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
|
||||
|
||||
let chatStateRehydrateRunId = 0;
|
||||
|
||||
/**
|
||||
* Commits the tracker data from the last assistant message to be used as source for next generation.
|
||||
* This should be called when the user has replied to a message, ensuring all swipes of the next
|
||||
@@ -87,6 +89,237 @@ export function commitTrackerData() {
|
||||
}
|
||||
}
|
||||
|
||||
function getSwipeTrackerData(message) {
|
||||
return getMessageSwipeTrackerData(message);
|
||||
}
|
||||
|
||||
function getCurrentSwipeTrackerData(message) {
|
||||
return getCurrentMessageSwipeTrackerData(message);
|
||||
}
|
||||
|
||||
function hasAssistantMessageBody() {
|
||||
const $messages = $('#chat .mes');
|
||||
|
||||
for (let i = $messages.length - 1; i >= 0; i--) {
|
||||
const $message = $messages.eq(i);
|
||||
if ($message.attr('is_user') === 'true') continue;
|
||||
if ($message.find('.mes_text').length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnyTrackerStateInChat() {
|
||||
const chatMessages = getContext()?.chat || [];
|
||||
|
||||
for (let i = chatMessages.length - 1; i >= 0; i--) {
|
||||
const swipeData = getSwipeTrackerData(chatMessages[i]);
|
||||
if (swipeData?.userStats || swipeData?.infoBox || swipeData?.characterThoughts) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAssistantMessagesInChat() {
|
||||
const chatMessages = getContext()?.chat || [];
|
||||
return chatMessages.some(message => message && !message.is_user && !message.is_system);
|
||||
}
|
||||
|
||||
function hasPotentialTrackerSourceInChat() {
|
||||
const chatMessages = getContext()?.chat || [];
|
||||
|
||||
for (const message of chatMessages) {
|
||||
if (!message || message.is_user || message.is_system) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.extra?.rpg_companion_swipes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(message.swipe_info) && message.swipe_info.some(info => info?.extra?.rpg_companion_swipes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(message.swipes) && message.swipes.length > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function maybeRehydrateUserStatsFromDisplayData() {
|
||||
const hasSavedUserStats = !!chat_metadata?.rpg_companion?.userStats;
|
||||
if (!hasSavedUserStats && lastGeneratedData.userStats) {
|
||||
try {
|
||||
parseUserStats(lastGeneratedData.userStats);
|
||||
} catch (error) {
|
||||
console.warn('[RPG Companion] Failed to rebuild user stats from display data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentSwipeText(message) {
|
||||
const swipeId = Number(message?.swipe_id ?? 0);
|
||||
|
||||
if (Array.isArray(message?.swipes) && typeof message.swipes[swipeId] === 'string' && message.swipes[swipeId].trim()) {
|
||||
return message.swipes[swipeId];
|
||||
}
|
||||
|
||||
return typeof message?.mes === 'string' ? message.mes : '';
|
||||
}
|
||||
|
||||
function repairLatestTrackerStateFromCurrentSwipeContent(chatMessages = getContext()?.chat || []) {
|
||||
for (let i = chatMessages.length - 1; i >= 0; i--) {
|
||||
const message = chatMessages[i];
|
||||
if (!message || message.is_user || message.is_system) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const swipeId = Number(message.swipe_id ?? 0);
|
||||
if (getCurrentSwipeTrackerData(message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentSwipeText = getCurrentSwipeText(message);
|
||||
if (!currentSwipeText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedData = parseResponse(currentSwipeText);
|
||||
if (parsedData.userStats) {
|
||||
parsedData.userStats = removeLocks(parsedData.userStats);
|
||||
}
|
||||
if (parsedData.infoBox) {
|
||||
parsedData.infoBox = removeLocks(parsedData.infoBox);
|
||||
}
|
||||
if (parsedData.characterThoughts) {
|
||||
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
|
||||
}
|
||||
|
||||
if (!parsedData.userStats && !parsedData.infoBox && !parsedData.characterThoughts) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setMessageSwipeTrackerData(message, swipeId, {
|
||||
userStats: parsedData.userStats || null,
|
||||
infoBox: parsedData.infoBox || null,
|
||||
characterThoughts: parsedData.characterThoughts || null
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function restoreOrRepairLatestTrackerState() {
|
||||
const chatMessages = getContext()?.chat || [];
|
||||
let restored = restoreLatestTrackerStateFromChat(chatMessages);
|
||||
|
||||
if (!restored) {
|
||||
const repaired = repairLatestTrackerStateFromCurrentSwipeContent(chatMessages);
|
||||
if (repaired) {
|
||||
restored = restoreLatestTrackerStateFromChat(chatMessages);
|
||||
}
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
function rerenderRpgState() {
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
renderThoughts();
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
renderMusicPlayer($musicPlayerContainer[0]);
|
||||
updateFabWidgets();
|
||||
updateStripWidgets();
|
||||
}
|
||||
|
||||
export function scheduleChatStateRehydration() {
|
||||
chatStateRehydrateRunId++;
|
||||
const runId = chatStateRehydrateRunId;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 15;
|
||||
const eagerRetryAttempts = 4;
|
||||
|
||||
const tryRestoreState = () => {
|
||||
if (runId !== chatStateRehydrateRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
|
||||
loadChatData();
|
||||
restoreOrRepairLatestTrackerState();
|
||||
maybeRehydrateUserStatsFromDisplayData();
|
||||
rerenderRpgState();
|
||||
|
||||
const hasRestoredTrackerState = !!(
|
||||
lastGeneratedData.userStats
|
||||
|| lastGeneratedData.infoBox
|
||||
|| lastGeneratedData.characterThoughts
|
||||
|| committedTrackerData.userStats
|
||||
|| committedTrackerData.infoBox
|
||||
|| committedTrackerData.characterThoughts
|
||||
);
|
||||
const hasStoredTrackerState = !!chat_metadata?.rpg_companion || hasAnyTrackerStateInChat();
|
||||
const hasAssistantMessages = hasAssistantMessagesInChat();
|
||||
const hasPotentialTrackerSource = hasPotentialTrackerSourceInChat();
|
||||
const chatBodyReady = hasAssistantMessageBody();
|
||||
|
||||
if (chatBodyReady) {
|
||||
updateChatThoughts();
|
||||
}
|
||||
|
||||
const shouldRetryForRestore = !hasRestoredTrackerState && (
|
||||
hasStoredTrackerState
|
||||
|| (hasAssistantMessages && attempts < eagerRetryAttempts)
|
||||
|| (hasPotentialTrackerSource && attempts < maxAttempts)
|
||||
);
|
||||
|
||||
const shouldRetryForDom = !chatBodyReady && hasAssistantMessages;
|
||||
|
||||
if ((shouldRetryForRestore || shouldRetryForDom) && attempts < maxAttempts) {
|
||||
setTimeout(tryRestoreState, 200);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(tryRestoreState, 200);
|
||||
}
|
||||
|
||||
export function onChatLoaded() {
|
||||
loadChatData();
|
||||
restoreOrRepairLatestTrackerState();
|
||||
maybeRehydrateUserStatsFromDisplayData();
|
||||
rerenderRpgState();
|
||||
scheduleChatStateRehydration();
|
||||
updateAllCheckpointIndicators();
|
||||
}
|
||||
|
||||
function syncDisplayedTrackerStateFromChat() {
|
||||
const restored = restoreOrRepairLatestTrackerState();
|
||||
|
||||
if (!restored) {
|
||||
lastGeneratedData.userStats = null;
|
||||
lastGeneratedData.infoBox = null;
|
||||
lastGeneratedData.characterThoughts = null;
|
||||
committedTrackerData.userStats = null;
|
||||
committedTrackerData.infoBox = null;
|
||||
committedTrackerData.characterThoughts = null;
|
||||
}
|
||||
|
||||
rerenderRpgState();
|
||||
updateChatThoughts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler for when the user sends a message.
|
||||
* Sets the flag to indicate this is NOT a swipe.
|
||||
@@ -193,11 +426,11 @@ export async function onMessageReceived(data) {
|
||||
}
|
||||
|
||||
const currentSwipeId = lastMessage.swipe_id || 0;
|
||||
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
|
||||
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
|
||||
userStats: parsedData.userStats,
|
||||
infoBox: parsedData.infoBox,
|
||||
characterThoughts: parsedData.characterThoughts
|
||||
};
|
||||
});
|
||||
|
||||
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId);
|
||||
|
||||
@@ -244,6 +477,11 @@ export async function onMessageReceived(data) {
|
||||
|
||||
// console.log('[RPG Companion] Cleaned message, removed tracker code blocks from DOM');
|
||||
|
||||
// Re-insert chat thoughts after SillyTavern finishes rerendering the cleaned message DOM.
|
||||
if (parsedData.characterThoughts) {
|
||||
setTimeout(() => updateChatThoughts(), 100);
|
||||
}
|
||||
|
||||
// Save to chat metadata
|
||||
saveChatData();
|
||||
}
|
||||
@@ -310,6 +548,7 @@ export function onCharacterChanged() {
|
||||
// Remove thought panel and icon when changing characters
|
||||
$('#rpg-thought-panel').remove();
|
||||
$('#rpg-thought-icon').remove();
|
||||
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
|
||||
$('#chat').off('scroll.thoughtPanel');
|
||||
$(window).off('resize.thoughtPanel');
|
||||
$(document).off('click.thoughtPanel');
|
||||
@@ -328,20 +567,9 @@ export function onCharacterChanged() {
|
||||
// already contains the committed state from when we last left this chat.
|
||||
// commitTrackerData() will be called naturally when new messages arrive.
|
||||
|
||||
// Re-render with the loaded data
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
renderThoughts();
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
renderMusicPlayer($musicPlayerContainer[0]);
|
||||
|
||||
// Update FAB widgets and strip widgets with loaded data
|
||||
updateFabWidgets();
|
||||
updateStripWidgets();
|
||||
|
||||
// Update chat thought overlays
|
||||
updateChatThoughts();
|
||||
// Re-render with the loaded data and retry once SillyTavern finishes restoring chat state.
|
||||
rerenderRpgState();
|
||||
scheduleChatStateRehydration();
|
||||
|
||||
// Update checkpoint indicators for the loaded chat
|
||||
updateAllCheckpointIndicators();
|
||||
@@ -366,6 +594,7 @@ export function onMessageSwiped(messageIndex) {
|
||||
}
|
||||
|
||||
const currentSwipeId = message.swipe_id || 0;
|
||||
const swipeCount = Array.isArray(message.swipes) ? message.swipes.length : 0;
|
||||
|
||||
// Only set flag to true if this swipe will trigger a NEW generation
|
||||
// Check if the swipe already exists (has content in the swipes array)
|
||||
@@ -373,6 +602,8 @@ export function onMessageSwiped(messageIndex) {
|
||||
message.swipes[currentSwipeId] !== undefined &&
|
||||
message.swipes[currentSwipeId] !== null &&
|
||||
message.swipes[currentSwipeId].length > 0;
|
||||
const swipeData = getCurrentSwipeTrackerData(message);
|
||||
const isPendingNewSwipe = currentSwipeId >= swipeCount;
|
||||
|
||||
if (!isExistingSwipe) {
|
||||
// This is a NEW swipe that will trigger generation
|
||||
@@ -384,13 +615,16 @@ export function onMessageSwiped(messageIndex) {
|
||||
// console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
|
||||
}
|
||||
|
||||
if (isPendingNewSwipe) {
|
||||
lastGeneratedData.characterThoughts = null;
|
||||
}
|
||||
|
||||
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
|
||||
|
||||
// IMPORTANT: onMessageSwiped is for DISPLAY only!
|
||||
// lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION
|
||||
// It's safe to load swipe data into lastGeneratedData - it won't be committed due to !lastActionWasSwipe check
|
||||
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
|
||||
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
|
||||
if (swipeData) {
|
||||
|
||||
// Load swipe data into lastGeneratedData for display (both modes)
|
||||
lastGeneratedData.userStats = swipeData.userStats || null;
|
||||
@@ -417,7 +651,7 @@ export function onMessageSwiped(messageIndex) {
|
||||
// Re-render the panels
|
||||
renderUserStats();
|
||||
renderInfoBox();
|
||||
renderThoughts();
|
||||
renderThoughts({ useCommittedFallback: !isPendingNewSwipe });
|
||||
renderInventory();
|
||||
renderQuests();
|
||||
renderMusicPlayer($musicPlayerContainer[0]);
|
||||
@@ -426,6 +660,15 @@ export function onMessageSwiped(messageIndex) {
|
||||
updateChatThoughts();
|
||||
}
|
||||
|
||||
export function onMessageDeleted() {
|
||||
if (!extensionSettings.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncDisplayedTrackerStateFromChat();
|
||||
saveChatData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the persona avatar image when user switches personas
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
addDebugLog
|
||||
} from '../../core/state.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { saveChatData, saveSettings } from '../../core/persistence.js';
|
||||
import { saveChatData, saveSettings, getCurrentMessageSwipeTrackerData, setMessageSwipeTrackerField } from '../../core/persistence.js';
|
||||
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
|
||||
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
|
||||
|
||||
@@ -154,7 +154,7 @@ function namesMatch(cardName, aiName) {
|
||||
* Displays character cards with avatars, relationship badges, and traits.
|
||||
* Includes event listeners for editable character fields.
|
||||
*/
|
||||
export function renderThoughts({ preserveScroll = false } = {}) {
|
||||
export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
|
||||
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
|
||||
return;
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
|
||||
}
|
||||
|
||||
// Don't render if no data exists (e.g., after cache clear)
|
||||
const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts;
|
||||
const thoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null);
|
||||
if (!thoughtsData) {
|
||||
$thoughtsContainer.html('<div class="rpg-inventory-empty">No character data generated yet</div>');
|
||||
return;
|
||||
@@ -193,7 +193,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
|
||||
const hasRelationshipEnabled = relationshipFields.length > 0;
|
||||
|
||||
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
|
||||
const characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || '';
|
||||
const characterThoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || '';
|
||||
|
||||
// console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts));
|
||||
// console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts));
|
||||
@@ -839,7 +839,7 @@ export function removeCharacter(characterName) {
|
||||
if (message.extra && message.extra.rpg_companion_swipes) {
|
||||
const swipeId = message.swipe_id || 0;
|
||||
if (message.extra.rpg_companion_swipes[swipeId]) {
|
||||
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts;
|
||||
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -969,7 +969,7 @@ export function addNewCharacter() {
|
||||
if (message.extra && message.extra.rpg_companion_swipes) {
|
||||
const swipeId = message.swipe_id || 0;
|
||||
if (message.extra.rpg_companion_swipes[swipeId]) {
|
||||
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts;
|
||||
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1152,7 +1152,7 @@ export function updateCharacterField(characterName, field, value) {
|
||||
if (message.extra && message.extra.rpg_companion_swipes) {
|
||||
const swipeId = message.swipe_id || 0;
|
||||
if (message.extra.rpg_companion_swipes[swipeId]) {
|
||||
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts;
|
||||
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1381,7 +1381,7 @@ export function updateCharacterField(characterName, field, value) {
|
||||
if (message.extra && message.extra.rpg_companion_swipes) {
|
||||
const swipeId = message.swipe_id || 0;
|
||||
if (message.extra.rpg_companion_swipes[swipeId]) {
|
||||
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lines.join('\n');
|
||||
setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lines.join('\n'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1438,66 +1438,265 @@ function renderThoughtsSidebarOnly() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates or removes thought overlays in the chat.
|
||||
* Creates floating thought bubbles positioned near character avatars.
|
||||
* Updates or removes thoughts shown in chat.
|
||||
* Renders either the original corner bubbles or inline dropdown cards.
|
||||
*/
|
||||
export function updateChatThoughts() {
|
||||
// console.log('[RPG Companion] ======== updateChatThoughts called ========');
|
||||
// console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled);
|
||||
// console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat);
|
||||
// console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked'));
|
||||
// console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
|
||||
|
||||
// Remove existing thought panel and icon
|
||||
let inlineThoughtsObserver = null;
|
||||
let inlineThoughtsRefreshTimeout = null;
|
||||
let isRefreshingInlineThoughts = false;
|
||||
|
||||
export function updateChatThoughts(attempt = 0) {
|
||||
const thoughtsStyle = extensionSettings.thoughtsInChatStyle || 'corner';
|
||||
const openInlineThoughts = getOpenInlineThoughts();
|
||||
|
||||
// Remove old floating thought panel/icon (legacy cleanup)
|
||||
$('#rpg-thought-panel').remove();
|
||||
$('#rpg-thought-icon').remove();
|
||||
$('#chat').off('scroll.thoughtPanel');
|
||||
$(window).off('resize.thoughtPanel');
|
||||
$(document).off('click.thoughtPanel');
|
||||
|
||||
// If extension is disabled, thoughts in chat are disabled, or no thoughts, just return
|
||||
if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) {
|
||||
// console.log('[RPG Companion] Thoughts in chat disabled or no data');
|
||||
// Remove any existing inline thought dropdowns from previous renders
|
||||
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
|
||||
|
||||
const canRenderThoughts = extensionSettings.enabled
|
||||
&& extensionSettings.showThoughtsInChat
|
||||
&& !!lastGeneratedData.characterThoughts;
|
||||
|
||||
if (!canRenderThoughts) {
|
||||
teardownInlineThoughtsObserver();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the Present Characters data to get thoughts
|
||||
let thoughtsArray = []; // Array of {name, emoji, thought}
|
||||
const thoughtsArray = parseThoughtsArray();
|
||||
|
||||
if (thoughtsArray.length === 0) {
|
||||
teardownInlineThoughtsObserver();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetInfo = findThoughtTargetMessage();
|
||||
let $targetMessage = targetInfo.$message;
|
||||
|
||||
if ((!$targetMessage || !$targetMessage.length || !$targetMessage.find('.mes_text').length) && attempt < 10) {
|
||||
setTimeout(() => updateChatThoughts(attempt + 1), 120);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$targetMessage || !$targetMessage.length) {
|
||||
teardownInlineThoughtsObserver();
|
||||
return;
|
||||
}
|
||||
|
||||
if (thoughtsStyle === 'inline') {
|
||||
insertInlineThoughts($targetMessage, thoughtsArray, openInlineThoughts);
|
||||
ensureInlineThoughtsObserver();
|
||||
} else {
|
||||
teardownInlineThoughtsObserver();
|
||||
createThoughtPanel($targetMessage, thoughtsArray);
|
||||
}
|
||||
}
|
||||
|
||||
function findThoughtTargetMessage() {
|
||||
const context = getContext();
|
||||
const chat = context?.chat || [];
|
||||
const currentThoughts = normalizeThoughtPayload(lastGeneratedData.characterThoughts);
|
||||
|
||||
let fallbackIndex = -1;
|
||||
|
||||
// Match the currently displayed thoughts against stored swipe payloads so the
|
||||
// UI stays attached to the visible assistant reply after swipes, deletes, and reloads.
|
||||
for (let i = chat.length - 1; i >= 0; i--) {
|
||||
const message = chat[i];
|
||||
if (message?.is_user) continue;
|
||||
|
||||
if (fallbackIndex === -1) {
|
||||
fallbackIndex = i;
|
||||
}
|
||||
|
||||
const messageThoughts = getMessageThoughtPayload(message);
|
||||
if (!currentThoughts || !messageThoughts) continue;
|
||||
|
||||
if (messageThoughts === currentThoughts) {
|
||||
const $message = $(`#chat .mes[mesid="${i}"]`);
|
||||
return { index: i, $message };
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackIndex !== -1) {
|
||||
return {
|
||||
index: fallbackIndex,
|
||||
$message: $(`#chat .mes[mesid="${fallbackIndex}"]`)
|
||||
};
|
||||
}
|
||||
|
||||
return { index: -1, $message: $() };
|
||||
}
|
||||
|
||||
function getMessageThoughtPayload(message) {
|
||||
if (!message || message.is_user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const swipeData = getCurrentMessageSwipeTrackerData(message);
|
||||
return normalizeThoughtPayload(swipeData?.characterThoughts ?? null);
|
||||
}
|
||||
|
||||
function normalizeThoughtPayload(payload) {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof payload === 'object') {
|
||||
return stableStringify(payload);
|
||||
}
|
||||
|
||||
if (typeof payload !== 'string') {
|
||||
return String(payload);
|
||||
}
|
||||
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return stableStringify(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return trimmed.replace(/\r\n/g, '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(item => stableStringify(item)).join(',')}]`;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function getOpenInlineThoughts() {
|
||||
const openThoughts = new Set();
|
||||
|
||||
$('.rpg-inline-thought[open]').each(function () {
|
||||
const characterName = ($(this).attr('data-character') || '').trim();
|
||||
if (characterName) {
|
||||
openThoughts.add(characterName);
|
||||
}
|
||||
});
|
||||
|
||||
return openThoughts;
|
||||
}
|
||||
|
||||
function ensureInlineThoughtsObserver() {
|
||||
if (inlineThoughtsObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatElement = document.getElementById('chat');
|
||||
if (!chatElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
inlineThoughtsObserver = new MutationObserver((mutations) => {
|
||||
if (isRefreshingInlineThoughts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// SillyTavern rerenders message DOM after swipes, deletes, and message cleanup.
|
||||
// Watch for those chat-level mutations and reattach inline thoughts once the
|
||||
// target message body exists again, while ignoring our own refresh churn.
|
||||
const shouldRefresh = mutations.some((mutation) => {
|
||||
if (mutation.type !== 'childList') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const touchedThoughtNode = [...mutation.addedNodes, ...mutation.removedNodes].some((node) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return node.classList?.contains('mes')
|
||||
|| node.classList?.contains('mes_text')
|
||||
|| node.querySelector?.('.mes, .mes_text');
|
||||
});
|
||||
|
||||
return touchedThoughtNode;
|
||||
});
|
||||
|
||||
if (!shouldRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(inlineThoughtsRefreshTimeout);
|
||||
inlineThoughtsRefreshTimeout = setTimeout(() => {
|
||||
if (!extensionSettings.enabled
|
||||
|| !extensionSettings.showThoughtsInChat
|
||||
|| (extensionSettings.thoughtsInChatStyle || 'corner') !== 'inline') {
|
||||
teardownInlineThoughtsObserver();
|
||||
return;
|
||||
}
|
||||
|
||||
isRefreshingInlineThoughts = true;
|
||||
try {
|
||||
updateChatThoughts();
|
||||
} finally {
|
||||
isRefreshingInlineThoughts = false;
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
inlineThoughtsObserver.observe(chatElement, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
function teardownInlineThoughtsObserver() {
|
||||
if (inlineThoughtsRefreshTimeout) {
|
||||
clearTimeout(inlineThoughtsRefreshTimeout);
|
||||
inlineThoughtsRefreshTimeout = null;
|
||||
}
|
||||
|
||||
if (inlineThoughtsObserver) {
|
||||
inlineThoughtsObserver.disconnect();
|
||||
inlineThoughtsObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseThoughtsArray() {
|
||||
let thoughtsArray = [];
|
||||
const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
|
||||
const thoughtsLabel = thoughtsConfig?.name || 'Thoughts';
|
||||
|
||||
// Try JSON format first
|
||||
try {
|
||||
const parsed = typeof lastGeneratedData.characterThoughts === 'string'
|
||||
? JSON.parse(lastGeneratedData.characterThoughts)
|
||||
: lastGeneratedData.characterThoughts;
|
||||
|
||||
// Handle both {characters: [...]} and direct array formats
|
||||
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
|
||||
|
||||
if (charactersArray.length > 0) {
|
||||
// Extract thoughts from JSON character objects
|
||||
const offScene = /\b(not\s+(currently\s+)?(in|at|present|in\s+the)\s+(the\s+)?(scene|area|room|location|vicinity))\b|\b(off[\s-]?scene)\b|\b(not\s+present)\b|\b(absent)\b|\b(away\s+from\s+(the\s+)?scene)\b/i;
|
||||
thoughtsArray = charactersArray
|
||||
.filter(char => char.thoughts && char.thoughts.content)
|
||||
.filter(char => char.thoughts && char.thoughts.content && !offScene.test(char.thoughts.content))
|
||||
.map(char => ({
|
||||
name: (char.name || '').toLowerCase(),
|
||||
name: (char.name || ''),
|
||||
emoji: char.emoji || '👤',
|
||||
thought: char.thoughts.content
|
||||
}));
|
||||
|
||||
debugLog('[RPG Thoughts Bubble] ✓ Parsed JSON format, thoughts:', thoughtsArray.length);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLog('[RPG Thoughts Bubble] Not JSON format, falling back to text parsing');
|
||||
}
|
||||
|
||||
// If JSON parsing failed or returned empty, try text format
|
||||
if (thoughtsArray.length === 0) {
|
||||
if (thoughtsArray.length === 0 && lastGeneratedData.characterThoughts) {
|
||||
const lines = lastGeneratedData.characterThoughts.split('\n');
|
||||
|
||||
// console.log('[RPG Companion] Parsing thoughts from lines:', lines);
|
||||
|
||||
// Parse new format to build character map and thoughts
|
||||
let currentCharName = null;
|
||||
let currentCharEmoji = null;
|
||||
|
||||
@@ -1513,74 +1712,103 @@ export function updateChatThoughts() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a character name line (starts with "- ")
|
||||
if (line.startsWith('- ')) {
|
||||
const name = line.substring(2).trim();
|
||||
if (name && name.toLowerCase() !== 'unavailable') {
|
||||
currentCharName = name;
|
||||
currentCharEmoji = null; // Reset emoji for new character
|
||||
currentCharEmoji = null;
|
||||
} else {
|
||||
currentCharName = null;
|
||||
currentCharEmoji = null;
|
||||
}
|
||||
}
|
||||
// Check if this is a Details line (contains the emoji)
|
||||
else if (line.startsWith('Details:') && currentCharName) {
|
||||
} else if (line.startsWith('Details:') && currentCharName) {
|
||||
const detailsContent = line.substring(line.indexOf(':') + 1).trim();
|
||||
const parts = detailsContent.split('|').map(p => p.trim());
|
||||
|
||||
// First part is the emoji
|
||||
if (parts.length > 0) {
|
||||
currentCharEmoji = parts[0];
|
||||
}
|
||||
}
|
||||
// Check if this is a Thoughts line
|
||||
else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
|
||||
} else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
|
||||
const thoughtContent = line.substring(thoughtsLabel.length + 1).trim();
|
||||
|
||||
// The thought content is just the text (no emoji prefix in new format)
|
||||
if (thoughtContent) {
|
||||
thoughtsArray.push({
|
||||
name: currentCharName.toLowerCase(),
|
||||
name: currentCharName,
|
||||
emoji: currentCharEmoji,
|
||||
thought: thoughtContent
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} // End of text format parsing for thoughts bubbles
|
||||
}
|
||||
|
||||
debugLog('[RPG Thoughts] Parsed thoughts:', thoughtsArray);
|
||||
return thoughtsArray;
|
||||
}
|
||||
|
||||
// If no thoughts parsed, return
|
||||
if (thoughtsArray.length === 0) {
|
||||
// console.log('[RPG Companion] No thoughts parsed, returning');
|
||||
function escapeInlineThoughtHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.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 = $('<div class="rpg-inline-thoughts"></div>');
|
||||
bindInlineThoughtEvents($container);
|
||||
|
||||
for (const [, thoughtData] of Object.entries(thoughtsMap)) {
|
||||
const $dropdown = createInlineThoughtDropdown(thoughtData, openThoughts);
|
||||
$container.append($dropdown);
|
||||
}
|
||||
|
||||
if (!$container.children().length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the thought panel with all thoughts
|
||||
createThoughtPanel($targetMessage, thoughtsArray);
|
||||
// Mount outside .mes_text so SillyTavern's click-to-edit handlers do not
|
||||
// intercept summary clicks before the details element can toggle.
|
||||
const $mediaWrapper = $message.find('.mes_media_wrapper').first();
|
||||
if ($mediaWrapper.length) {
|
||||
$container.insertBefore($mediaWrapper);
|
||||
} else {
|
||||
$container.insertAfter($mesText.last());
|
||||
}
|
||||
}
|
||||
|
||||
function bindInlineThoughtEvents($container) {
|
||||
$container.on('click mousedown touchstart', '.rpg-inline-thought, .rpg-inline-thought-summary, .rpg-inline-thought-content', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
function createInlineThoughtDropdown(thoughtData, openThoughts = new Set()) {
|
||||
const characterName = thoughtData.name || '';
|
||||
const characterEmoji = thoughtData.emoji || '👤';
|
||||
const thoughtText = thoughtData.thought || '';
|
||||
const normalizedCharacterName = characterName.toLowerCase();
|
||||
const openAttribute = openThoughts.has(normalizedCharacterName) ? ' open' : '';
|
||||
|
||||
return $(`
|
||||
<details class="rpg-inline-thought" data-character="${escapeInlineThoughtHtml(normalizedCharacterName)}"${openAttribute}>
|
||||
<summary class="rpg-inline-thought-summary">
|
||||
<span class="rpg-inline-thought-icon">${escapeInlineThoughtHtml(characterEmoji)}</span>
|
||||
<span class="rpg-inline-thought-name">${escapeInlineThoughtHtml(characterName)}'s thoughts</span>
|
||||
</summary>
|
||||
<div class="rpg-inline-thought-content">
|
||||
<div class="rpg-inline-thought-text">${escapeInlineThoughtHtml(thoughtText)}</div>
|
||||
</div>
|
||||
</details>
|
||||
`);
|
||||
}
|
||||
|
||||
// ===== GLOBAL DRAGGING SETUP FOR THOUGHT ICON (MOBILE ONLY) =====
|
||||
|
||||
Reference in New Issue
Block a user