Alternate Thoughts Display (#2)

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

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

The new display mode is built on top of RPG Companion’s existing thoughts system rather than replacing it. Thought UI now updates more reliably across new generations, swipe changes, message deletion, chat reload/re-entry, and live mode toggling, so thoughts stay attached to the correct visible message instead of lingering on stale UI. It also improves restoration of RPG Companion state after reopening a chat, making thoughts and related tracker data more consistent with the current chat view.
This commit is contained in:
Tremendoussly
2026-03-08 19:54:38 +01:00
committed by GitHub
parent 502646bb92
commit ae9e44eafb
12 changed files with 926 additions and 113 deletions
+266 -12
View File
@@ -21,6 +21,242 @@ import { migrateToV3JSON } from '../utils/jsonMigration.js';
const extensionName = 'third-party/rpg-companion-sillytavern';
function hasTrackerPayload(payload) {
return !!(payload && typeof payload === 'object' && (
payload.userStats
|| payload.infoBox
|| payload.characterThoughts
));
}
function getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
if (!store) {
return null;
}
if (hasTrackerPayload(store)) {
return store;
}
const preferredKey = String(preferredSwipeId);
const preferredPayload = store[preferredKey] ?? store[preferredSwipeId];
if (hasTrackerPayload(preferredPayload)) {
return preferredPayload;
}
return null;
}
function getTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
const currentPayload = getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId);
if (currentPayload) {
return currentPayload;
}
if (!store || typeof store !== 'object') {
return null;
}
const numericKeys = Object.keys(store)
.filter(key => /^\d+$/.test(key))
.sort((a, b) => Number(b) - Number(a));
for (const key of numericKeys) {
const payload = store[key];
if (hasTrackerPayload(payload)) {
return payload;
}
}
for (const payload of Object.values(store)) {
if (hasTrackerPayload(payload)) {
return payload;
}
}
return null;
}
function ensureTrackerPayloadSlot(store, swipeId = 0) {
if (!store || typeof store !== 'object' || Array.isArray(store)) {
return null;
}
if (hasTrackerPayload(store)) {
return store;
}
if (!store[swipeId] || typeof store[swipeId] !== 'object' || Array.isArray(store[swipeId])) {
store[swipeId] = {};
}
return store[swipeId];
}
function ensureSwipeInfoEntry(message, swipeId = 0) {
if (!Array.isArray(message?.swipe_info)) {
return null;
}
if (!message.swipe_info[swipeId] || typeof message.swipe_info[swipeId] !== 'object') {
message.swipe_info[swipeId] = {
send_date: message.send_date,
gen_started: message.gen_started,
gen_finished: message.gen_finished,
extra: {}
};
}
if (!message.swipe_info[swipeId].extra || typeof message.swipe_info[swipeId].extra !== 'object') {
message.swipe_info[swipeId].extra = {};
}
return message.swipe_info[swipeId];
}
export function getCurrentMessageSwipeTrackerData(message) {
if (!message || message.is_user) {
return null;
}
const swipeId = Number(message.swipe_id ?? 0);
const preferredSources = [
message.extra?.rpg_companion_swipes,
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
];
for (const source of preferredSources) {
const payload = getCurrentTrackerPayloadFromSwipeStore(source, swipeId);
if (payload) {
return payload;
}
}
return null;
}
export function getMessageSwipeTrackerData(message) {
if (!message || message.is_user) {
return null;
}
const swipeId = Number(message.swipe_id ?? 0);
const currentPayload = getCurrentMessageSwipeTrackerData(message);
if (currentPayload) {
return currentPayload;
}
const preferredSources = [
message.extra?.rpg_companion_swipes,
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
];
for (const source of preferredSources) {
const payload = getTrackerPayloadFromSwipeStore(source, swipeId);
if (payload) {
return payload;
}
}
if (Array.isArray(message.swipe_info)) {
for (let i = message.swipe_info.length - 1; i >= 0; i--) {
const payload = getTrackerPayloadFromSwipeStore(message.swipe_info[i]?.extra?.rpg_companion_swipes, swipeId);
if (payload) {
return payload;
}
}
}
return null;
}
export function getLatestTrackerDataFromChat(chatMessages) {
if (!Array.isArray(chatMessages)) {
return null;
}
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (message?.is_user) continue;
const swipeData = getCurrentMessageSwipeTrackerData(message);
if (!swipeData) continue;
return {
userStats: swipeData.userStats || null,
infoBox: swipeData.infoBox || null,
characterThoughts: typeof swipeData.characterThoughts === 'object'
? JSON.stringify(swipeData.characterThoughts, null, 2)
: (swipeData.characterThoughts || null)
};
}
return null;
}
export function restoreLatestTrackerStateFromChat(chatMessages) {
const latestData = getLatestTrackerDataFromChat(chatMessages);
if (!latestData) {
return false;
}
setLastGeneratedData({
userStats: latestData.userStats || null,
infoBox: latestData.infoBox || null,
characterThoughts: latestData.characterThoughts || null,
html: lastGeneratedData.html || null
});
setCommittedTrackerData({
userStats: latestData.userStats || committedTrackerData.userStats || null,
infoBox: latestData.infoBox || committedTrackerData.infoBox || null,
characterThoughts: latestData.characterThoughts || committedTrackerData.characterThoughts || null
});
return true;
}
export function setMessageSwipeTrackerData(message, swipeId = 0, trackerData = {}) {
if (!message || message.is_user || !trackerData || typeof trackerData !== 'object') {
return null;
}
if (!message.extra || typeof message.extra !== 'object') {
message.extra = {};
}
if (!message.extra.rpg_companion_swipes || typeof message.extra.rpg_companion_swipes !== 'object' || Array.isArray(message.extra.rpg_companion_swipes)) {
message.extra.rpg_companion_swipes = {};
}
const extraPayload = ensureTrackerPayloadSlot(message.extra.rpg_companion_swipes, swipeId);
if (extraPayload) {
Object.assign(extraPayload, trackerData);
}
const swipeInfoEntry = ensureSwipeInfoEntry(message, swipeId);
if (swipeInfoEntry) {
if (!swipeInfoEntry.extra.rpg_companion_swipes || typeof swipeInfoEntry.extra.rpg_companion_swipes !== 'object' || Array.isArray(swipeInfoEntry.extra.rpg_companion_swipes)) {
swipeInfoEntry.extra.rpg_companion_swipes = {};
}
const swipePayload = ensureTrackerPayloadSlot(swipeInfoEntry.extra.rpg_companion_swipes, swipeId);
if (swipePayload) {
Object.assign(swipePayload, trackerData);
}
}
return extraPayload;
}
export function setMessageSwipeTrackerField(message, swipeId = 0, field, value) {
if (!field) {
return null;
}
return setMessageSwipeTrackerData(message, swipeId, { [field]: value });
}
/**
* Validates extension settings structure
* @param {Object} settings - Settings object to validate
@@ -134,6 +370,12 @@ export function loadSettings() {
settingsChanged = true;
}
// Normalize additive settings without introducing another schema bump.
if (!extensionSettings.thoughtsInChatStyle) {
extensionSettings.thoughtsInChatStyle = 'corner';
settingsChanged = true;
}
// Save migrated settings
if (settingsChanged) {
saveSettings();
@@ -247,11 +489,11 @@ export function updateMessageSwipeData() {
}
const swipeId = message.swipe_id || 0;
message.extra.rpg_companion_swipes[swipeId] = {
setMessageSwipeTrackerData(message, swipeId, {
userStats: lastGeneratedData.userStats,
infoBox: lastGeneratedData.infoBox,
characterThoughts: lastGeneratedData.characterThoughts
};
});
// console.log('[RPG Companion] Updated message swipe data after user edit');
break;
@@ -264,8 +506,10 @@ export function updateMessageSwipeData() {
* Automatically migrates v1 inventory to v2 format if needed.
*/
export function loadChatData() {
if (!chat_metadata || !chat_metadata.rpg_companion) {
// Reset to defaults if no data exists
const savedData = chat_metadata?.rpg_companion;
if (!savedData) {
// Reset to defaults if no metadata exists, then try to rebuild from message swipe data below.
updateExtensionSettings({
userStats: {
health: 100,
@@ -299,23 +543,20 @@ export function loadChatData() {
infoBox: null,
characterThoughts: null
});
return;
}
const savedData = chat_metadata.rpg_companion;
// Restore stats
if (savedData.userStats) {
if (savedData?.userStats) {
extensionSettings.userStats = { ...savedData.userStats };
}
// Restore classic stats
if (savedData.classicStats) {
if (savedData?.classicStats) {
extensionSettings.classicStats = { ...savedData.classicStats };
}
// Restore quests
if (savedData.quests) {
if (savedData?.quests) {
extensionSettings.quests = { ...savedData.quests };
} else {
// Initialize with defaults if not present
@@ -326,7 +567,7 @@ export function loadChatData() {
}
// Restore committed tracker data first
if (savedData.committedTrackerData) {
if (savedData?.committedTrackerData) {
// console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', {
// userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null',
@@ -343,7 +584,7 @@ export function loadChatData() {
// Restore last generated data (for display)
// Always prefer lastGeneratedData as it contains the most recent generation (including swipes)
if (savedData.lastGeneratedData) {
if (savedData?.lastGeneratedData) {
// console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData');
setLastGeneratedData({ ...savedData.lastGeneratedData });
} else {
@@ -363,6 +604,19 @@ export function loadChatData() {
// Validate inventory structure (Bug #3 fix)
validateInventoryStructure(extensionSettings.userStats.inventory, 'chat');
// Sync display data from the latest assistant message's stored swipe payload.
// This is more reliable than chat metadata alone on chat re-entry because the
// latest rendered swipe data may exist on the message even if the debounced
// metadata save did not flush yet.
try {
const chatContext = getContext();
const chatMessages = chatContext?.chat;
restoreLatestTrackerStateFromChat(chatMessages);
} catch (e) {
console.warn('[RPG Companion] Per-message data sync skipped:', e.message);
}
// console.log('[RPG Companion] Loaded chat data:', savedData);
}