Merge pull request #135 from Tremendoussly/doom-lite-expression-sync-v2

Add optional alternate tracker displays and expression sync
This commit is contained in:
Spicy Marinara
2026-04-14 18:26:56 +02:00
committed by GitHub
25 changed files with 3086 additions and 326 deletions
+116 -2
View File
@@ -134,6 +134,20 @@ import {
removeDesktopTabs, removeDesktopTabs,
updateStripWidgets updateStripWidgets
} from './src/systems/ui/desktop.js'; } 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 // Feature modules
import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js'; import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js';
@@ -150,8 +164,10 @@ import {
onMessageSent, onMessageSent,
onMessageReceived, onMessageReceived,
onCharacterChanged, onCharacterChanged,
onMessageSwiped, onChatLoaded,
onMessageDeleted, onMessageDeleted,
onMessageSwiped,
scheduleChatStateRehydration,
updatePersonaAvatar, updatePersonaAvatar,
clearExtensionPrompts, clearExtensionPrompts,
onGenerationEnded, onGenerationEnded,
@@ -161,6 +177,10 @@ import {
// Old state variable declarations removed - now imported from core modules // Old state variable declarations removed - now imported from core modules
// (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js) // (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 // Utility functions removed - now imported from src/utils/avatars.js
// (getSafeThumbnailUrl) // (getSafeThumbnailUrl)
@@ -217,6 +237,7 @@ async function addExtensionSettings() {
clearExtensionPrompts(); clearExtensionPrompts();
updateChatThoughts(); // Remove thought bubbles updateChatThoughts(); // Remove thought bubbles
cleanupCheckpointUI(); // Remove checkpoint buttons and indicators cleanupCheckpointUI(); // Remove checkpoint buttons and indicators
clearThoughtBasedExpressionsCache();
// Disable dynamic weather effects // Disable dynamic weather effects
toggleDynamicWeather(false); toggleDynamicWeather(false);
@@ -226,10 +247,13 @@ async function addExtensionSettings() {
$('#rpg-mobile-toggle').remove(); $('#rpg-mobile-toggle').remove();
$('#rpg-collapse-toggle').remove(); $('#rpg-collapse-toggle').remove();
$('#rpg-plot-buttons').remove(); // Remove plot buttons $('#rpg-plot-buttons').remove(); // Remove plot buttons
removeAlternatePresentCharactersPanel();
} else if (extensionSettings.enabled && !wasEnabled) { } else if (extensionSettings.enabled && !wasEnabled) {
// Enabling extension - initialize UI // Enabling extension - initialize UI
await initUI(); await initUI();
loadChatData(); // Load chat data for current chat loadChatData(); // Load chat data for current chat
scheduleChatStateRehydration();
initThoughtBasedExpressions();
updateChatThoughts(); // Create thought bubbles if data exists updateChatThoughts(); // Create thought bubbles if data exists
injectCheckpointButton(); // Re-add checkpoint buttons injectCheckpointButton(); // Re-add checkpoint buttons
updateAllCheckpointIndicators(); // Update button states updateAllCheckpointIndicators(); // Update button states
@@ -336,6 +360,26 @@ async function initUI() {
extensionSettings.showCharacterThoughts = $(this).prop('checked'); extensionSettings.showCharacterThoughts = $(this).prop('checked');
saveSettings(); saveSettings();
updateSectionVisibility(); 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() { $('#rpg-toggle-inventory').on('change', function() {
@@ -368,6 +412,12 @@ async function initUI() {
updateChatThoughts(); updateChatThoughts();
}); });
$('#rpg-toggle-inline-thoughts').on('change', function() {
extensionSettings.thoughtsInChatStyle = $(this).prop('checked') ? 'inline' : 'corner';
saveSettings();
updateChatThoughts();
});
$('#rpg-toggle-html-prompt').on('change', function() { $('#rpg-toggle-html-prompt').on('change', function() {
extensionSettings.enableHtmlPrompt = $(this).prop('checked'); extensionSettings.enableHtmlPrompt = $(this).prop('checked');
// console.log('[RPG Companion] Toggle enableHtmlPrompt changed to:', extensionSettings.enableHtmlPrompt); // console.log('[RPG Companion] Toggle enableHtmlPrompt changed to:', extensionSettings.enableHtmlPrompt);
@@ -1066,10 +1116,14 @@ async function initUI() {
$('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats); $('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats);
$('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox); $('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox);
$('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts); $('#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-inventory').prop('checked', extensionSettings.showInventory);
$('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests); $('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true); $('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
$('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat); $('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat);
$('#rpg-toggle-inline-thoughts').prop('checked', (extensionSettings.thoughtsInChatStyle || 'corner') === 'inline');
$('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt); $('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt);
$('#rpg-toggle-dialogue-coloring').prop('checked', extensionSettings.enableDialogueColoring); $('#rpg-toggle-dialogue-coloring').prop('checked', extensionSettings.enableDialogueColoring);
$('#rpg-toggle-deception').prop('checked', extensionSettings.enableDeceptionSystem ?? false); $('#rpg-toggle-deception').prop('checked', extensionSettings.enableDeceptionSystem ?? false);
@@ -1306,6 +1360,8 @@ jQuery(async () => {
// Load chat-specific data for current chat // Load chat-specific data for current chat
try { try {
loadChatData(); loadChatData();
scheduleChatStateRehydration();
initThoughtBasedExpressions();
// Initialize FAB widgets and strip widgets with any loaded data // Initialize FAB widgets and strip widgets with any loaded data
updateFabWidgets(); updateFabWidgets();
updateStripWidgets(); updateStripWidgets();
@@ -1376,11 +1432,69 @@ jQuery(async () => {
[event_types.GENERATION_STOPPED]: onGenerationEnded, [event_types.GENERATION_STOPPED]: onGenerationEnded,
[event_types.GENERATION_ENDED]: onGenerationEnded, [event_types.GENERATION_ENDED]: onGenerationEnded,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts], [event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts],
[event_types.MESSAGE_SWIPED]: onMessageSwiped, [event_types.CHAT_LOADED]: onChatLoaded,
[event_types.MESSAGE_DELETED]: onMessageDeleted, [event_types.MESSAGE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPE_DELETED]: onMessageDeleted,
[event_types.MESSAGE_SWIPED]: onMessageSwiped,
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar, [event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar [event_types.SETTINGS_UPDATED]: updatePersonaAvatar
}); });
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) { } catch (error) {
console.error('[RPG Companion] Event registration failed:', error); console.error('[RPG Companion] Event registration failed:', error);
throw error; // This is critical - can't continue without events throw error; // This is critical - can't continue without events
+4
View File
@@ -29,10 +29,14 @@ export const defaultSettings = {
showUserStats: true, showUserStats: true,
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system) showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
enableHtmlPrompt: false, // Enable immersive HTML prompt injection enableHtmlPrompt: false, // Enable immersive HTML prompt injection
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
+288 -17
View File
@@ -9,10 +9,13 @@ import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
committedTrackerData, committedTrackerData,
thoughtBasedExpressionPortraits,
setExtensionSettings, setExtensionSettings,
updateExtensionSettings, updateExtensionSettings,
setLastGeneratedData, setLastGeneratedData,
setCommittedTrackerData, setCommittedTrackerData,
setThoughtBasedExpressionPortraits,
clearThoughtBasedExpressionPortraits,
FEATURE_FLAGS FEATURE_FLAGS
} from './state.js'; } from './state.js';
import { migrateInventory } from '../utils/migration.js'; import { migrateInventory } from '../utils/migration.js';
@@ -21,6 +24,242 @@ import { migrateToV3JSON } from '../utils/jsonMigration.js';
const extensionName = 'third-party/rpg-companion-sillytavern'; const extensionName = 'third-party/rpg-companion-sillytavern';
function hasTrackerPayload(payload) {
return !!(payload && typeof payload === 'object' && (
payload.userStats
|| payload.infoBox
|| payload.characterThoughts
));
}
function getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
if (!store) {
return null;
}
if (hasTrackerPayload(store)) {
return store;
}
const preferredKey = String(preferredSwipeId);
const preferredPayload = store[preferredKey] ?? store[preferredSwipeId];
if (hasTrackerPayload(preferredPayload)) {
return preferredPayload;
}
return null;
}
function getTrackerPayloadFromSwipeStore(store, preferredSwipeId = 0) {
const currentPayload = getCurrentTrackerPayloadFromSwipeStore(store, preferredSwipeId);
if (currentPayload) {
return currentPayload;
}
if (!store || typeof store !== 'object') {
return null;
}
const numericKeys = Object.keys(store)
.filter(key => /^\d+$/.test(key))
.sort((a, b) => Number(b) - Number(a));
for (const key of numericKeys) {
const payload = store[key];
if (hasTrackerPayload(payload)) {
return payload;
}
}
for (const payload of Object.values(store)) {
if (hasTrackerPayload(payload)) {
return payload;
}
}
return null;
}
function ensureTrackerPayloadSlot(store, swipeId = 0) {
if (!store || typeof store !== 'object' || Array.isArray(store)) {
return null;
}
if (hasTrackerPayload(store)) {
return store;
}
if (!store[swipeId] || typeof store[swipeId] !== 'object' || Array.isArray(store[swipeId])) {
store[swipeId] = {};
}
return store[swipeId];
}
function ensureSwipeInfoEntry(message, swipeId = 0) {
if (!Array.isArray(message?.swipe_info)) {
return null;
}
if (!message.swipe_info[swipeId] || typeof message.swipe_info[swipeId] !== 'object') {
message.swipe_info[swipeId] = {
send_date: message.send_date,
gen_started: message.gen_started,
gen_finished: message.gen_finished,
extra: {}
};
}
if (!message.swipe_info[swipeId].extra || typeof message.swipe_info[swipeId].extra !== 'object') {
message.swipe_info[swipeId].extra = {};
}
return message.swipe_info[swipeId];
}
export function getCurrentMessageSwipeTrackerData(message) {
if (!message || message.is_user) {
return null;
}
const swipeId = Number(message.swipe_id ?? 0);
const preferredSources = [
message.extra?.rpg_companion_swipes,
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
];
for (const source of preferredSources) {
const payload = getCurrentTrackerPayloadFromSwipeStore(source, swipeId);
if (payload) {
return payload;
}
}
return null;
}
export function getMessageSwipeTrackerData(message) {
if (!message || message.is_user) {
return null;
}
const swipeId = Number(message.swipe_id ?? 0);
const currentPayload = getCurrentMessageSwipeTrackerData(message);
if (currentPayload) {
return currentPayload;
}
const preferredSources = [
message.extra?.rpg_companion_swipes,
message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes
];
for (const source of preferredSources) {
const payload = getTrackerPayloadFromSwipeStore(source, swipeId);
if (payload) {
return payload;
}
}
if (Array.isArray(message.swipe_info)) {
for (let i = message.swipe_info.length - 1; i >= 0; i--) {
const payload = getTrackerPayloadFromSwipeStore(message.swipe_info[i]?.extra?.rpg_companion_swipes, swipeId);
if (payload) {
return payload;
}
}
}
return null;
}
export function getLatestTrackerDataFromChat(chatMessages) {
if (!Array.isArray(chatMessages)) {
return null;
}
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (message?.is_user) continue;
const swipeData = getCurrentMessageSwipeTrackerData(message);
if (!swipeData) continue;
return {
userStats: swipeData.userStats || null,
infoBox: swipeData.infoBox || null,
characterThoughts: typeof swipeData.characterThoughts === 'object'
? JSON.stringify(swipeData.characterThoughts, null, 2)
: (swipeData.characterThoughts || null)
};
}
return null;
}
export function restoreLatestTrackerStateFromChat(chatMessages) {
const latestData = getLatestTrackerDataFromChat(chatMessages);
if (!latestData) {
return false;
}
setLastGeneratedData({
userStats: latestData.userStats || null,
infoBox: latestData.infoBox || null,
characterThoughts: latestData.characterThoughts || null,
html: lastGeneratedData.html || null
});
setCommittedTrackerData({
userStats: latestData.userStats || committedTrackerData.userStats || null,
infoBox: latestData.infoBox || committedTrackerData.infoBox || null,
characterThoughts: latestData.characterThoughts || committedTrackerData.characterThoughts || null
});
return true;
}
export function setMessageSwipeTrackerData(message, swipeId = 0, trackerData = {}) {
if (!message || message.is_user || !trackerData || typeof trackerData !== 'object') {
return null;
}
if (!message.extra || typeof message.extra !== 'object') {
message.extra = {};
}
if (!message.extra.rpg_companion_swipes || typeof message.extra.rpg_companion_swipes !== 'object' || Array.isArray(message.extra.rpg_companion_swipes)) {
message.extra.rpg_companion_swipes = {};
}
const extraPayload = ensureTrackerPayloadSlot(message.extra.rpg_companion_swipes, swipeId);
if (extraPayload) {
Object.assign(extraPayload, trackerData);
}
const swipeInfoEntry = ensureSwipeInfoEntry(message, swipeId);
if (swipeInfoEntry) {
if (!swipeInfoEntry.extra.rpg_companion_swipes || typeof swipeInfoEntry.extra.rpg_companion_swipes !== 'object' || Array.isArray(swipeInfoEntry.extra.rpg_companion_swipes)) {
swipeInfoEntry.extra.rpg_companion_swipes = {};
}
const swipePayload = ensureTrackerPayloadSlot(swipeInfoEntry.extra.rpg_companion_swipes, swipeId);
if (swipePayload) {
Object.assign(swipePayload, trackerData);
}
}
return extraPayload;
}
export function setMessageSwipeTrackerField(message, swipeId = 0, field, value) {
if (!field) {
return null;
}
return setMessageSwipeTrackerData(message, swipeId, { [field]: value });
}
/** /**
* Validates extension settings structure * Validates extension settings structure
* @param {Object} settings - Settings object to validate * @param {Object} settings - Settings object to validate
@@ -134,6 +373,22 @@ export function loadSettings() {
settingsChanged = true; 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 // Save migrated settings
if (settingsChanged) { if (settingsChanged) {
saveSettings(); saveSettings();
@@ -218,6 +473,7 @@ export function saveChatData() {
quests: extensionSettings.quests, quests: extensionSettings.quests,
lastGeneratedData: lastGeneratedData, lastGeneratedData: lastGeneratedData,
committedTrackerData: committedTrackerData, committedTrackerData: committedTrackerData,
thoughtBasedExpressionPortraits: thoughtBasedExpressionPortraits,
timestamp: Date.now() timestamp: Date.now()
}; };
@@ -257,7 +513,7 @@ export function updateMessageSwipeData() {
// Find the last assistant message // Find the last assistant message
for (let i = chat.length - 1; i >= 0; i--) { for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i]; const message = chat[i];
if (!message.is_user) { if (!message.is_user && !message.is_system) {
// Found last assistant message - update its swipe data // Found last assistant message - update its swipe data
if (!message.extra) { if (!message.extra) {
message.extra = {}; message.extra = {};
@@ -267,15 +523,11 @@ export function updateMessageSwipeData() {
} }
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
const swipeEntry = { setMessageSwipeTrackerData(message, swipeId, {
userStats: lastGeneratedData.userStats, userStats: lastGeneratedData.userStats,
infoBox: lastGeneratedData.infoBox, infoBox: lastGeneratedData.infoBox,
characterThoughts: lastGeneratedData.characterThoughts 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'); // console.log('[RPG Companion] Updated message swipe data after user edit');
break; break;
@@ -405,8 +657,10 @@ export function inheritSwipeDataFromPriorMessage(message, messageIndex) {
* Automatically migrates v1 inventory to v2 format if needed. * Automatically migrates v1 inventory to v2 format if needed.
*/ */
export function loadChatData() { export function loadChatData() {
if (!chat_metadata || !chat_metadata.rpg_companion) { const savedData = chat_metadata?.rpg_companion;
// Reset to defaults if no data exists
if (!savedData) {
// Reset to defaults if no metadata exists, then try to rebuild from message swipe data below.
updateExtensionSettings({ updateExtensionSettings({
userStats: { userStats: {
health: 100, health: 100,
@@ -440,23 +694,21 @@ export function loadChatData() {
infoBox: null, infoBox: null,
characterThoughts: null characterThoughts: null
}); });
return; clearThoughtBasedExpressionPortraits();
} }
const savedData = chat_metadata.rpg_companion;
// Restore stats // Restore stats
if (savedData.userStats) { if (savedData?.userStats) {
extensionSettings.userStats = { ...savedData.userStats }; extensionSettings.userStats = { ...savedData.userStats };
} }
// Restore classic stats // Restore classic stats
if (savedData.classicStats) { if (savedData?.classicStats) {
extensionSettings.classicStats = { ...savedData.classicStats }; extensionSettings.classicStats = { ...savedData.classicStats };
} }
// Restore quests // Restore quests
if (savedData.quests) { if (savedData?.quests) {
extensionSettings.quests = { ...savedData.quests }; extensionSettings.quests = { ...savedData.quests };
} else { } else {
// Initialize with defaults if not present // Initialize with defaults if not present
@@ -467,7 +719,7 @@ export function loadChatData() {
} }
// Restore committed tracker data first // Restore committed tracker data first
if (savedData.committedTrackerData) { if (savedData?.committedTrackerData) {
// console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', { // console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', {
// userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null', // userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null', // infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null',
@@ -484,13 +736,19 @@ export function loadChatData() {
// Restore last generated data (for display) // Restore last generated data (for display)
// Always prefer lastGeneratedData as it contains the most recent generation (including swipes) // Always prefer lastGeneratedData as it contains the most recent generation (including swipes)
if (savedData.lastGeneratedData) { if (savedData?.lastGeneratedData) {
// console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData'); // console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData');
setLastGeneratedData({ ...savedData.lastGeneratedData }); setLastGeneratedData({ ...savedData.lastGeneratedData });
} else { } else {
// console.log('[RPG Companion] ⚠️ No lastGeneratedData found in save'); // 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 // Migrate inventory in chat data if feature flag enabled
if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) { if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) {
const migrationResult = migrateInventory(extensionSettings.userStats.inventory); const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
@@ -504,6 +762,19 @@ export function loadChatData() {
// Validate inventory structure (Bug #3 fix) // Validate inventory structure (Bug #3 fix)
validateInventoryStructure(extensionSettings.userStats.inventory, 'chat'); validateInventoryStructure(extensionSettings.userStats.inventory, 'chat');
// Sync display data from the latest assistant message's stored swipe payload.
// This is more reliable than chat metadata alone on chat re-entry because the
// latest rendered swipe data may exist on the message even if the debounced
// metadata save did not flush yet.
try {
const chatContext = getContext();
const chatMessages = chatContext?.chat;
restoreLatestTrackerStateFromChat(chatMessages);
} catch (e) {
console.warn('[RPG Companion] Per-message data sync skipped:', e.message);
}
// console.log('[RPG Companion] Loaded chat data:', savedData); // console.log('[RPG Companion] Loaded chat data:', savedData);
} }
+23 -1
View File
@@ -10,7 +10,7 @@
* Extension settings - persisted to SillyTavern settings * Extension settings - persisted to SillyTavern settings
*/ */
export let extensionSettings = { export let extensionSettings = {
settingsVersion: 4, // Version number for settings migrations (v4 = FAB widgets enabled by default) settingsVersion: 4, // Version number for settings migrations
enabled: true, enabled: true,
autoUpdate: false, autoUpdate: false,
updateDepth: 4, // How many messages to include in the context updateDepth: 4, // How many messages to include in the context
@@ -18,9 +18,13 @@ export let extensionSettings = {
showUserStats: true, showUserStats: true,
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system) showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
narratorMode: false, // Use character card as narrator instead of fixed character references narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default) customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default) customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default)
@@ -359,6 +363,24 @@ export function clearSessionAvatarPrompts() {
sessionAvatarPrompts = {}; 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) * Tracks whether the last action was a swipe (for separate mode)
* Used to determine whether to commit lastGeneratedData to committedTrackerData * Used to determine whether to commit lastGeneratedData to committedTrackerData
+8
View File
@@ -36,6 +36,12 @@
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.", "template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters", "template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.", "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.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.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory", "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.showLockIconsNote": "Display lock/unlock icons on tracker items to prevent AI from modifying them.",
"template.settingsModal.display.showThoughtsInChat": "Show Thoughts", "template.settingsModal.display.showThoughtsInChat": "Show Thoughts",
"template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages.", "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.alwaysShowThoughtBubble": "Always Show Thought Bubble",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first",
"template.settingsModal.display.enableAnimations": "Enable Animations", "template.settingsModal.display.enableAnimations": "Enable Animations",
+8
View File
@@ -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.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.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.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.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.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", "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.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.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.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.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.alwaysShowThoughtBubbleNote": "Développer automatiquement la bulle de pensée sans cliquer sur l'icône d'abord",
"template.settingsModal.display.enableAnimations": "Activer Animations", "template.settingsModal.display.enableAnimations": "Activer Animations",
+8
View File
@@ -35,6 +35,12 @@
"template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.", "template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.",
"template.settingsModal.display.showPresentCharacters": "Показывать персонажей", "template.settingsModal.display.showPresentCharacters": "Показывать персонажей",
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.", "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.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.", "template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь", "template.settingsModal.display.showInventory": "Показывать инвентарь",
@@ -45,6 +51,8 @@
"template.settingsModal.display.showLockIconsNote": "Отображать значки блокировки/разблокировки на элементах трекера, чтобы предотвратить их изменение ИИ.", "template.settingsModal.display.showLockIconsNote": "Отображать значки блокировки/разблокировки на элементах трекера, чтобы предотвратить их изменение ИИ.",
"template.settingsModal.display.showThoughtsInChat": "Показывать мысли", "template.settingsModal.display.showThoughtsInChat": "Показывать мысли",
"template.settingsModal.display.showThoughtsInChatNote": "Отображать мысли персонажей в виде всплывающих пузырьков рядом с их сообщениями.", "template.settingsModal.display.showThoughtsInChatNote": "Отображать мысли персонажей в виде всплывающих пузырьков рядом с их сообщениями.",
"template.settingsModal.display.showInlineThoughts": "Показывать мысли под текстом сообщения",
"template.settingsModal.display.showInlineThoughtsNote": "Переключает между стандартными угловыми пузырями мыслей и карточками мыслей под текстом сообщения.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Всегда показывать пузырь мыслей", "template.settingsModal.display.alwaysShowThoughtBubble": "Всегда показывать пузырь мыслей",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Автоматически раскрывать пузырь мыслей без предварительного нажатия на значок", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "Автоматически раскрывать пузырь мыслей без предварительного нажатия на значок",
"template.settingsModal.display.enableAnimations": "Включить анимации", "template.settingsModal.display.enableAnimations": "Включить анимации",
+8
View File
@@ -36,6 +36,12 @@
"template.settingsModal.display.showInfoBoxNote": "显示位置、时间、天气和最近事件。", "template.settingsModal.display.showInfoBoxNote": "显示位置、时间、天气和最近事件。",
"template.settingsModal.display.showPresentCharacters": "显示在场角色", "template.settingsModal.display.showPresentCharacters": "显示在场角色",
"template.settingsModal.display.showPresentCharactersNote": "显示角色肖像及其当前想法和状态。", "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.narratorMode": "旁白模式",
"template.settingsModal.display.narratorModeNote": "使用角色卡作为旁白。根据上下文推断角色,而非使用固定的角色引用。", "template.settingsModal.display.narratorModeNote": "使用角色卡作为旁白。根据上下文推断角色,而非使用固定的角色引用。",
"template.settingsModal.display.showInventory": "显示物品栏", "template.settingsModal.display.showInventory": "显示物品栏",
@@ -46,6 +52,8 @@
"template.settingsModal.display.showLockIconsNote": "在跟踪器项目上显示锁定/解锁图标,以防止 AI 修改它们。", "template.settingsModal.display.showLockIconsNote": "在跟踪器项目上显示锁定/解锁图标,以防止 AI 修改它们。",
"template.settingsModal.display.showThoughtsInChat": "显示想法", "template.settingsModal.display.showThoughtsInChat": "显示想法",
"template.settingsModal.display.showThoughtsInChatNote": "将角色想法显示为其消息旁边的气泡。", "template.settingsModal.display.showThoughtsInChatNote": "将角色想法显示为其消息旁边的气泡。",
"template.settingsModal.display.showInlineThoughts": "在消息文本下方显示想法",
"template.settingsModal.display.showInlineThoughtsNote": "在默认角落想法气泡和显示在消息文本下方的想法卡片之间切换。",
"template.settingsModal.display.alwaysShowThoughtBubble": "始终显示想法气泡", "template.settingsModal.display.alwaysShowThoughtBubble": "始终显示想法气泡",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "自动展开想法气泡,无需先点击图标", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "自动展开想法气泡,无需先点击图标",
"template.settingsModal.display.enableAnimations": "启用动画", "template.settingsModal.display.enableAnimations": "启用动画",
+8
View File
@@ -32,11 +32,19 @@
"template.settingsModal.display.showUserStats": "顯示 user 屬性", "template.settingsModal.display.showUserStats": "顯示 user 屬性",
"template.settingsModal.display.showInfoBox": "顯示資訊框", "template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色", "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.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器", "template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。", "template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
"template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法", "template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法",
"template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡", "template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡",
"template.settingsModal.display.showInlineThoughts": "在訊息文字下方顯示想法",
"template.settingsModal.display.showInlineThoughtsNote": "在預設角落想法泡泡與顯示在訊息文字下方的想法卡片之間切換。",
"template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡", "template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡",
"template.settingsModal.display.enableAnimations": "啟用動畫", "template.settingsModal.display.enableAnimations": "啟用動畫",
+3 -7
View File
@@ -21,7 +21,7 @@ import {
$musicPlayerContainer, $musicPlayerContainer,
getSeparateGenerationId getSeparateGenerationId
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData, mirrorToSwipeInfo } from '../../core/persistence.js'; import { saveChatData, setMessageSwipeTrackerData } from '../../core/persistence.js';
import { import {
generateSeparateUpdatePrompt generateSeparateUpdatePrompt
} from './promptBuilder.js'; } from './promptBuilder.js';
@@ -326,15 +326,11 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
} }
const currentSwipeId = lastMessage.swipe_id || 0; const currentSwipeId = lastMessage.swipe_id || 0;
const swipeEntry = { setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats, userStats: parsedData.userStats,
infoBox: parsedData.infoBox, infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts 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); // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
} }
+2 -1
View File
@@ -9,6 +9,7 @@ import { selected_group, getGroupMembers, groups } from '../../../../../../group
import { extensionSettings, committedTrackerData } from '../../core/state.js'; import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { currentEncounter } from '../features/encounterState.js'; import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js'; import { repairJSON } from '../../utils/jsonRepair.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js'; import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.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`; 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 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 += `\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 += `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`; summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`;
+6 -5
View File
@@ -14,6 +14,7 @@ import {
addLockInstruction addLockInstruction
} from './jsonPromptHelpers.js'; } from './jsonPromptHelpers.js';
import { applyLocks } from './lockManager.js'; import { applyLocks } from './lockManager.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -293,7 +294,7 @@ export function generateTrackerExample() {
} }
} }
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try { try {
JSON.parse(committedTrackerData.characterThoughts); JSON.parse(committedTrackerData.characterThoughts);
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters'); const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
@@ -329,7 +330,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
let instructions = ''; let instructions = '';
// Check if any trackers are enabled // 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 // Only add tracker instructions if at least one tracker is enabled
if (hasAnyTrackers) { if (hasAnyTrackers) {
@@ -360,7 +361,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
if (extensionSettings.showInfoBox) { if (extensionSettings.showInfoBox) {
enabledTrackers.push('infoBox'); enabledTrackers.push('infoBox');
} }
if (extensionSettings.showCharacterThoughts) { if (isPresentCharactersEnabled()) {
enabledTrackers.push('characters'); enabledTrackers.push('characters');
} }
@@ -383,7 +384,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n'; instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n';
} }
if (extensionSettings.showCharacterThoughts) { if (isPresentCharactersEnabled()) {
instructions += ' "characters": '; instructions += ' "characters": ';
const charactersJSON = buildCharactersJSONInstruction(); const charactersJSON = buildCharactersJSONInstruction();
// Add 2 spaces to all lines after the first to properly nest within root object // 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 // Add Present Characters tracker data if enabled
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try { try {
const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName); const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName);
if (formatted) { if (formatted) {
+276 -77
View File
@@ -4,7 +4,7 @@
*/ */
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, saveChatDebounced } from '../../../../../../../script.js'; import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js';
// Core modules // Core modules
import { import {
@@ -23,7 +23,18 @@ import {
$musicPlayerContainer, $musicPlayerContainer,
incrementSeparateGenerationId incrementSeparateGenerationId
} from '../../core/state.js'; } 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'; import { i18n } from '../../core/i18n.js';
// Generation & Parsing // Generation & Parsing
@@ -52,6 +63,8 @@ import { updateStripWidgets } from '../ui/desktop.js';
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js'; import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
let chatStateRehydrateRunId = 0;
/** /**
* Reads the swipe store of the last assistant message in `currentChat` and * Reads the swipe store of the last assistant message in `currentChat` and
* writes its data into `lastGeneratedData`, including syncing stat bars via * 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. * Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe. * Sets the flag to indicate this is NOT a swipe.
@@ -228,15 +472,11 @@ export async function onMessageReceived(data) {
} }
const currentSwipeId = lastMessage.swipe_id || 0; const currentSwipeId = lastMessage.swipe_id || 0;
const swipeEntry = { setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats, userStats: parsedData.userStats,
infoBox: parsedData.infoBox, infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts 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); // 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'); // console.log('[RPG Companion] Cleaned message, removed tracker code blocks from DOM');
// Re-insert chat thoughts after SillyTavern finishes rerendering the cleaned message DOM.
if (parsedData.characterThoughts) {
setTimeout(() => updateChatThoughts(), 100);
}
// Save to chat metadata // Save to chat metadata
saveChatData(); saveChatData();
} }
@@ -362,6 +607,7 @@ export function onCharacterChanged() {
// Remove thought panel and icon when changing characters // Remove thought panel and icon when changing characters
$('#rpg-thought-panel').remove(); $('#rpg-thought-panel').remove();
$('#rpg-thought-icon').remove(); $('#rpg-thought-icon').remove();
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
$('#chat').off('scroll.thoughtPanel'); $('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel'); $(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel'); $(document).off('click.thoughtPanel');
@@ -396,20 +642,9 @@ export function onCharacterChanged() {
// already contains the committed state from when we last left this chat. // already contains the committed state from when we last left this chat.
// commitTrackerData() will be called naturally when new messages arrive. // commitTrackerData() will be called naturally when new messages arrive.
// Re-render with the loaded data // Re-render with the loaded data and retry once SillyTavern finishes restoring chat state.
renderUserStats(); rerenderRpgState();
renderInfoBox(); scheduleChatStateRehydration();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with loaded data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Update checkpoint indicators for the loaded chat // Update checkpoint indicators for the loaded chat
updateAllCheckpointIndicators(); updateAllCheckpointIndicators();
@@ -434,6 +669,7 @@ export function onMessageSwiped(messageIndex) {
} }
const currentSwipeId = message.swipe_id || 0; const currentSwipeId = message.swipe_id || 0;
const swipeCount = Array.isArray(message.swipes) ? message.swipes.length : 0;
// Only set flag to true if this swipe will trigger a NEW generation // Only set flag to true if this swipe will trigger a NEW generation
// Check if the swipe already exists (has content in the swipes array) // Check if the swipe already exists (has content in the swipes array)
@@ -441,6 +677,8 @@ export function onMessageSwiped(messageIndex) {
message.swipes[currentSwipeId] !== undefined && message.swipes[currentSwipeId] !== undefined &&
message.swipes[currentSwipeId] !== null && message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0; message.swipes[currentSwipeId].length > 0;
const swipeData = getCurrentSwipeTrackerData(message);
const isPendingNewSwipe = currentSwipeId >= swipeCount;
if (!isExistingSwipe) { if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation // This is a NEW swipe that will trigger generation
@@ -455,13 +693,15 @@ export function onMessageSwiped(messageIndex) {
// console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe); // console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
} }
if (isPendingNewSwipe) {
lastGeneratedData.characterThoughts = null;
}
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId); // console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load saved swipe data into both display (lastGeneratedData) and extensionSettings. // Load saved swipe data for the active swipe only.
// Safe to call parseUserStats() unconditionally because updateMessageSwipeData() is called // Using the current-swipe helper here avoids falling back to another
// on every manual edit, so the swipe store always reflects the latest user changes before // stored swipe payload and showing stale tracker state.
// any navigation can overwrite them.
const swipeData = getSwipeData(message, currentSwipeId);
if (swipeData) { if (swipeData) {
// Load swipe data into lastGeneratedData for display (both modes) // Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null; lastGeneratedData.userStats = swipeData.userStats || null;
@@ -500,24 +740,16 @@ export function onMessageSwiped(messageIndex) {
updateChatThoughts(); 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() { export function onMessageDeleted() {
if (!extensionSettings.enabled) return; if (!extensionSettings.enabled) {
return;
// console.log('[RPG Companion] 🗑️ EVENT: onMessageDeleted'); }
// Invalidate any pending or in-flight separate-mode generation so // Invalidate any pending or in-flight separate-mode generation so
// its result is not applied to the (now-changed) chat tail. // its result is not applied to the (now-changed) chat tail.
incrementSeparateGenerationId(); incrementSeparateGenerationId();
const currentChat = getContext().chat; const currentChat = getContext().chat || [];
// Walk backward to find the new last assistant message.
let lastAssistantIndex = -1; let lastAssistantIndex = -1;
for (let i = currentChat.length - 1; i >= 0; i--) { for (let i = currentChat.length - 1; i >= 0; i--) {
if (!currentChat[i].is_user && !currentChat[i].is_system) { if (!currentChat[i].is_user && !currentChat[i].is_system) {
@@ -526,48 +758,15 @@ export function onMessageDeleted() {
} }
} }
if (lastAssistantIndex === -1) { syncDisplayedTrackerStateFromChat();
// 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.');
}
// Commit context from the message *before* the new tail assistant message, // After the display state has been rebuilt, restore generation context from
// so any subsequent generation uses the correct N-1 world state. // 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); commitTrackerDataFromPriorMessage(lastAssistantIndex);
} }
// Re-render all panels.
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update widget strips.
updateFabWidgets();
updateStripWidgets();
// Persist updated state.
saveChatData(); saveChatData();
} }
@@ -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();
}
+3 -3
View File
@@ -10,7 +10,7 @@ import {
committedTrackerData, committedTrackerData,
$infoBoxContainer $infoBoxContainer
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js'; import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js'; import { repairJSON } from '../../utils/jsonRepair.js';
@@ -989,7 +989,7 @@ export function updateInfoBoxField(field, value) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n'); setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
// console.log('[RPG Companion] Updated infoBox in message swipe data'); // console.log('[RPG Companion] Updated infoBox in message swipe data');
} }
} }
@@ -1074,7 +1074,7 @@ function updateRecentEvent(field, value) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n'); setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
} }
} }
break; break;
+322 -208
View File
@@ -4,20 +4,25 @@
*/ */
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { this_chid, characters } from '../../../../../../../script.js';
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
import { import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
committedTrackerData, committedTrackerData,
$thoughtsContainer, $thoughtsContainer,
FALLBACK_AVATAR_DATA_URI,
addDebugLog addDebugLog
} from '../../core/state.js'; } from '../../core/state.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { saveChatData, saveSettings } from '../../core/persistence.js'; import { saveChatData, saveSettings, getCurrentMessageSwipeTrackerData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import {
stripBrackets,
extractFieldValue,
toSnakeCase,
getPresentCharactersTrackerData,
resolvePresentCharacterPortrait
} from '../../utils/presentCharacters.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.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 * 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})`; 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. * Renders character thoughts (Present Characters) panel.
* Displays character cards with avatars, relationship badges, and traits. * Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields. * Includes event listeners for editable character fields.
*/ */
export function renderThoughts({ preserveScroll = false } = {}) { export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
renderAlternatePresentCharacters({ useCommittedFallback });
queueThoughtBasedExpressionsUpdate();
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return; return;
} }
@@ -169,7 +109,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
} }
// Don't render if no data exists (e.g., after cache clear) // Don't render if no data exists (e.g., after cache clear)
const thoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts; const thoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
if (!thoughtsData) { if (!thoughtsData) {
$thoughtsContainer.html('<div class="rpg-inventory-empty">' + (i18n.getTranslation('thoughts.empty') || 'No character data generated yet') + '</div>'); $thoughtsContainer.html('<div class="rpg-inventory-empty">' + (i18n.getTranslation('thoughts.empty') || 'No character data generated yet') + '</div>');
return; return;
@@ -193,7 +133,7 @@ export function renderThoughts({ preserveScroll = false } = {}) {
const hasRelationshipEnabled = relationshipFields.length > 0; const hasRelationshipEnabled = relationshipFields.length > 0;
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) // Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || ''; const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
// console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts)); // console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts));
// console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts)); // console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts));
@@ -416,70 +356,8 @@ export function renderThoughts({ preserveScroll = false } = {}) {
try { try {
debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name); 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}`); debugLog(`[RPG Thoughts] Looking up avatar for: ${char.name}`);
const characterPortrait = resolvePresentCharacterPortrait(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');
}
}
debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...'); debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...');
@@ -632,7 +510,9 @@ export function renderThoughts({ preserveScroll = false } = {}) {
// Update icon // Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓'; 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.text(newIcon);
$icon.attr('title', newTitle); $icon.attr('title', newTitle);
@@ -839,7 +719,7 @@ export function removeCharacter(characterName) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
} }
} }
break; break;
@@ -969,7 +849,7 @@ export function addNewCharacter() {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
} }
} }
break; break;
@@ -1152,7 +1032,7 @@ export function updateCharacterField(characterName, field, value) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts; setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lastGeneratedData.characterThoughts);
} }
} }
break; break;
@@ -1381,7 +1261,7 @@ export function updateCharacterField(characterName, field, value) {
if (message.extra && message.extra.rpg_companion_swipes) { if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0; const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) { if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lines.join('\n'); setMessageSwipeTrackerField(message, swipeId, 'characterThoughts', lines.join('\n'));
} }
} }
break; break;
@@ -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] Is editing thoughts?', isEditingThoughts, 'Field:', field, 'Thoughts field name:', thoughtsFieldName);
// console.log('[RPG Companion] After update - lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); // console.log('[RPG Companion] After update - lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
if (field === 'name' || isEditingThoughts) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
}
if (isEditingThoughts && extensionSettings.showThoughtsInChat) { if (isEditingThoughts && extensionSettings.showThoughtsInChat) {
// console.log('[RPG Companion] Updating chat thought bubbles'); // console.log('[RPG Companion] Updating chat thought bubbles');
// Update chat thought bubbles when thoughts are edited // Update chat thought bubbles when thoughts are edited
@@ -1438,66 +1322,265 @@ function renderThoughtsSidebarOnly() {
} }
/** /**
* Updates or removes thought overlays in the chat. * Updates or removes thoughts shown in chat.
* Creates floating thought bubbles positioned near character avatars. * Renders either the original corner bubbles or inline dropdown cards.
*/ */
export function updateChatThoughts() {
// console.log('[RPG Companion] ======== updateChatThoughts called ========');
// console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled);
// console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat);
// console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked'));
// console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
// Remove existing thought panel and icon let inlineThoughtsObserver = null;
let inlineThoughtsRefreshTimeout = null;
let isRefreshingInlineThoughts = false;
export function updateChatThoughts(attempt = 0) {
const thoughtsStyle = extensionSettings.thoughtsInChatStyle || 'corner';
const openInlineThoughts = getOpenInlineThoughts();
// Remove old floating thought panel/icon (legacy cleanup)
$('#rpg-thought-panel').remove(); $('#rpg-thought-panel').remove();
$('#rpg-thought-icon').remove(); $('#rpg-thought-icon').remove();
$('#chat').off('scroll.thoughtPanel'); $('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel'); $(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel'); $(document).off('click.thoughtPanel');
// If extension is disabled, thoughts in chat are disabled, or no thoughts, just return // Remove any existing inline thought dropdowns from previous renders
if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { $('.rpg-inline-thoughts, .rpg-inline-thought').remove();
// console.log('[RPG Companion] Thoughts in chat disabled or no data');
const canRenderThoughts = extensionSettings.enabled
&& extensionSettings.showThoughtsInChat
&& !!lastGeneratedData.characterThoughts;
if (!canRenderThoughts) {
teardownInlineThoughtsObserver();
return; return;
} }
// Parse the Present Characters data to get thoughts const thoughtsArray = parseThoughtsArray();
let thoughtsArray = []; // Array of {name, emoji, thought}
if (thoughtsArray.length === 0) {
teardownInlineThoughtsObserver();
return;
}
const targetInfo = findThoughtTargetMessage();
let $targetMessage = targetInfo.$message;
if ((!$targetMessage || !$targetMessage.length || !$targetMessage.find('.mes_text').length) && attempt < 10) {
setTimeout(() => updateChatThoughts(attempt + 1), 120);
return;
}
if (!$targetMessage || !$targetMessage.length) {
teardownInlineThoughtsObserver();
return;
}
if (thoughtsStyle === 'inline') {
insertInlineThoughts($targetMessage, thoughtsArray, openInlineThoughts);
ensureInlineThoughtsObserver();
} else {
teardownInlineThoughtsObserver();
createThoughtPanel($targetMessage, thoughtsArray);
}
}
function findThoughtTargetMessage() {
const context = getContext();
const chat = context?.chat || [];
const currentThoughts = normalizeThoughtPayload(lastGeneratedData.characterThoughts);
let fallbackIndex = -1;
// Match the currently displayed thoughts against stored swipe payloads so the
// UI stays attached to the visible assistant reply after swipes, deletes, and reloads.
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (message?.is_user) continue;
if (fallbackIndex === -1) {
fallbackIndex = i;
}
const messageThoughts = getMessageThoughtPayload(message);
if (!currentThoughts || !messageThoughts) continue;
if (messageThoughts === currentThoughts) {
const $message = $(`#chat .mes[mesid="${i}"]`);
return { index: i, $message };
}
}
if (fallbackIndex !== -1) {
return {
index: fallbackIndex,
$message: $(`#chat .mes[mesid="${fallbackIndex}"]`)
};
}
return { index: -1, $message: $() };
}
function getMessageThoughtPayload(message) {
if (!message || message.is_user) {
return null;
}
const swipeData = getCurrentMessageSwipeTrackerData(message);
return normalizeThoughtPayload(swipeData?.characterThoughts ?? null);
}
function normalizeThoughtPayload(payload) {
if (!payload) {
return null;
}
if (typeof payload === 'object') {
return stableStringify(payload);
}
if (typeof payload !== 'string') {
return String(payload);
}
const trimmed = payload.trim();
if (!trimmed) {
return null;
}
try {
return stableStringify(JSON.parse(trimmed));
} catch {
return trimmed.replace(/\r\n/g, '\n');
}
}
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(item => stableStringify(item)).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
}
return JSON.stringify(value);
}
function getOpenInlineThoughts() {
const openThoughts = new Set();
$('.rpg-inline-thought[open]').each(function () {
const characterName = ($(this).attr('data-character') || '').trim();
if (characterName) {
openThoughts.add(characterName);
}
});
return openThoughts;
}
function ensureInlineThoughtsObserver() {
if (inlineThoughtsObserver) {
return;
}
const chatElement = document.getElementById('chat');
if (!chatElement) {
return;
}
inlineThoughtsObserver = new MutationObserver((mutations) => {
if (isRefreshingInlineThoughts) {
return;
}
// SillyTavern rerenders message DOM after swipes, deletes, and message cleanup.
// Watch for those chat-level mutations and reattach inline thoughts once the
// target message body exists again, while ignoring our own refresh churn.
const shouldRefresh = mutations.some((mutation) => {
if (mutation.type !== 'childList') {
return false;
}
const touchedThoughtNode = [...mutation.addedNodes, ...mutation.removedNodes].some((node) => {
if (!(node instanceof HTMLElement)) {
return false;
}
return node.classList?.contains('mes')
|| node.classList?.contains('mes_text')
|| node.querySelector?.('.mes, .mes_text');
});
return touchedThoughtNode;
});
if (!shouldRefresh) {
return;
}
clearTimeout(inlineThoughtsRefreshTimeout);
inlineThoughtsRefreshTimeout = setTimeout(() => {
if (!extensionSettings.enabled
|| !extensionSettings.showThoughtsInChat
|| (extensionSettings.thoughtsInChatStyle || 'corner') !== 'inline') {
teardownInlineThoughtsObserver();
return;
}
isRefreshingInlineThoughts = true;
try {
updateChatThoughts();
} finally {
isRefreshingInlineThoughts = false;
}
}, 50);
});
inlineThoughtsObserver.observe(chatElement, {
childList: true,
subtree: true
});
}
function teardownInlineThoughtsObserver() {
if (inlineThoughtsRefreshTimeout) {
clearTimeout(inlineThoughtsRefreshTimeout);
inlineThoughtsRefreshTimeout = null;
}
if (inlineThoughtsObserver) {
inlineThoughtsObserver.disconnect();
inlineThoughtsObserver = null;
}
}
function parseThoughtsArray() {
let thoughtsArray = [];
const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts; const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
const thoughtsLabel = thoughtsConfig?.name || 'Thoughts'; const thoughtsLabel = thoughtsConfig?.name || 'Thoughts';
// Try JSON format first
try { try {
const parsed = typeof lastGeneratedData.characterThoughts === 'string' const parsed = typeof lastGeneratedData.characterThoughts === 'string'
? JSON.parse(lastGeneratedData.characterThoughts) ? JSON.parse(lastGeneratedData.characterThoughts)
: lastGeneratedData.characterThoughts; : lastGeneratedData.characterThoughts;
// Handle both {characters: [...]} and direct array formats
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []); const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) { if (charactersArray.length > 0) {
// Extract thoughts from JSON character objects const offScene = /\b(not\s+(currently\s+)?(in|at|present|in\s+the)\s+(the\s+)?(scene|area|room|location|vicinity))\b|\b(off[\s-]?scene)\b|\b(not\s+present)\b|\b(absent)\b|\b(away\s+from\s+(the\s+)?scene)\b/i;
thoughtsArray = charactersArray thoughtsArray = charactersArray
.filter(char => char.thoughts && char.thoughts.content) .filter(char => char.thoughts && char.thoughts.content && !offScene.test(char.thoughts.content))
.map(char => ({ .map(char => ({
name: (char.name || '').toLowerCase(), name: (char.name || ''),
emoji: char.emoji || '👤', emoji: char.emoji || '👤',
thought: char.thoughts.content thought: char.thoughts.content
})); }));
debugLog('[RPG Thoughts Bubble] ✓ Parsed JSON format, thoughts:', thoughtsArray.length);
} }
} catch (e) { } catch (e) {
debugLog('[RPG Thoughts Bubble] Not JSON format, falling back to text parsing'); debugLog('[RPG Thoughts Bubble] Not JSON format, falling back to text parsing');
} }
// If JSON parsing failed or returned empty, try text format if (thoughtsArray.length === 0 && lastGeneratedData.characterThoughts) {
if (thoughtsArray.length === 0) {
const lines = lastGeneratedData.characterThoughts.split('\n'); const lines = lastGeneratedData.characterThoughts.split('\n');
// console.log('[RPG Companion] Parsing thoughts from lines:', lines);
// Parse new format to build character map and thoughts
let currentCharName = null; let currentCharName = null;
let currentCharEmoji = null; let currentCharEmoji = null;
@@ -1513,74 +1596,103 @@ export function updateChatThoughts() {
continue; continue;
} }
// Check if this is a character name line (starts with "- ")
if (line.startsWith('- ')) { if (line.startsWith('- ')) {
const name = line.substring(2).trim(); const name = line.substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') { if (name && name.toLowerCase() !== 'unavailable') {
currentCharName = name; currentCharName = name;
currentCharEmoji = null; // Reset emoji for new character currentCharEmoji = null;
} else { } else {
currentCharName = null; currentCharName = null;
currentCharEmoji = null; currentCharEmoji = null;
} }
} } else if (line.startsWith('Details:') && currentCharName) {
// Check if this is a Details line (contains the emoji)
else if (line.startsWith('Details:') && currentCharName) {
const detailsContent = line.substring(line.indexOf(':') + 1).trim(); const detailsContent = line.substring(line.indexOf(':') + 1).trim();
const parts = detailsContent.split('|').map(p => p.trim()); const parts = detailsContent.split('|').map(p => p.trim());
// First part is the emoji
if (parts.length > 0) { if (parts.length > 0) {
currentCharEmoji = parts[0]; currentCharEmoji = parts[0];
} }
} } else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
// Check if this is a Thoughts line
else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) {
const thoughtContent = line.substring(thoughtsLabel.length + 1).trim(); const thoughtContent = line.substring(thoughtsLabel.length + 1).trim();
// The thought content is just the text (no emoji prefix in new format)
if (thoughtContent) { if (thoughtContent) {
thoughtsArray.push({ thoughtsArray.push({
name: currentCharName.toLowerCase(), name: currentCharName,
emoji: currentCharEmoji, emoji: currentCharEmoji,
thought: thoughtContent thought: thoughtContent
}); });
} }
} }
} }
} // End of text format parsing for thoughts bubbles }
debugLog('[RPG Thoughts] Parsed thoughts:', thoughtsArray); return thoughtsArray;
}
// If no thoughts parsed, return function escapeInlineThoughtHtml(value) {
if (thoughtsArray.length === 0) { return String(value ?? '')
// console.log('[RPG Companion] No thoughts parsed, returning'); .replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function insertInlineThoughts($message, thoughtsArray, openThoughts = new Set()) {
const $mesText = $message.find('.mes_text');
if (!$mesText.length) {
return; return;
} }
// console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); const thoughtsMap = {};
// console.log('[RPG Companion] Thoughts array:', thoughtsArray); for (const thoughtData of thoughtsArray) {
thoughtsMap[(thoughtData.name || '').toLowerCase()] = thoughtData;
// Find the last message to position near
const $messages = $('#chat .mes');
let $targetMessage = null;
// Find the most recent non-user message
for (let i = $messages.length - 1; i >= 0; i--) {
const $message = $messages.eq(i);
if ($message.attr('is_user') !== 'true') {
$targetMessage = $message;
break;
}
} }
if (!$targetMessage) { const $container = $('<div class="rpg-inline-thoughts"></div>');
// console.log('[RPG Companion] No target message found'); bindInlineThoughtEvents($container);
for (const [, thoughtData] of Object.entries(thoughtsMap)) {
const $dropdown = createInlineThoughtDropdown(thoughtData, openThoughts);
$container.append($dropdown);
}
if (!$container.children().length) {
return; return;
} }
// Create the thought panel with all thoughts // Mount outside .mes_text so SillyTavern's click-to-edit handlers do not
createThoughtPanel($targetMessage, thoughtsArray); // intercept summary clicks before the details element can toggle.
const $mediaWrapper = $message.find('.mes_media_wrapper').first();
if ($mediaWrapper.length) {
$container.insertBefore($mediaWrapper);
} else {
$container.insertAfter($mesText.last());
}
}
function bindInlineThoughtEvents($container) {
$container.on('click mousedown touchstart', '.rpg-inline-thought, .rpg-inline-thought-summary, .rpg-inline-thought-content', function (e) {
e.stopPropagation();
});
}
function createInlineThoughtDropdown(thoughtData, openThoughts = new Set()) {
const characterName = thoughtData.name || '';
const characterEmoji = thoughtData.emoji || '👤';
const thoughtText = thoughtData.thought || '';
const normalizedCharacterName = characterName.toLowerCase();
const openAttribute = openThoughts.has(normalizedCharacterName) ? ' open' : '';
return $(`
<details class="rpg-inline-thought" data-character="${escapeInlineThoughtHtml(normalizedCharacterName)}"${openAttribute}>
<summary class="rpg-inline-thought-summary">
<span class="rpg-inline-thought-icon">${escapeInlineThoughtHtml(characterEmoji)}</span>
<span class="rpg-inline-thought-name">${escapeInlineThoughtHtml(characterName)}'s thoughts</span>
</summary>
<div class="rpg-inline-thought-content">
<div class="rpg-inline-thought-text">${escapeInlineThoughtHtml(thoughtText)}</div>
</div>
</details>
`);
} }
// ===== GLOBAL DRAGGING SETUP FOR THOUGHT ICON (MOBILE ONLY) ===== // ===== GLOBAL DRAGGING SETUP FOR THOUGHT ICON (MOBILE ONLY) =====
@@ -2326,7 +2438,9 @@ export function createThoughtPanel($message, thoughtsArray) {
// Update icon // Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓'; 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.text(newIcon);
$icon.attr('title', newTitle); $icon.attr('title', newTitle);
@@ -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 = $(`<div id="${PANEL_ID}" class="rpg-alt-present-characters" style="display:none;"></div>`);
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 = $('<div class="rpg-alt-present-character"></div>')
.attr('data-character-name', name)
.attr('title', name);
const $portrait = $('<div class="rpg-alt-present-character__portrait"></div>');
const $image = $('<img />')
.attr({
alt: name,
loading: 'lazy'
})
.on('error', handlePortraitLoadError);
if (portrait) {
$image.attr('src', portrait);
}
const $meta = $('<div class="rpg-alt-present-character__meta"></div>');
const $name = $('<div class="rpg-alt-present-character__name"></div>').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 = $('<div class="rpg-alt-present-characters__header"></div>');
const $headerTitle = $('<div class="rpg-alt-present-characters__title"></div>');
const $scroll = $('<div class="rpg-alt-present-characters__scroll"></div>');
const $track = $('<div class="rpg-alt-present-characters__track"></div>');
$headerTitle.append(
$('<i class="fa-solid fa-users" aria-hidden="true"></i>'),
$('<span></span>').text(title)
);
$header.append(
$headerTitle,
$('<div class="rpg-alt-present-characters__count"></div>').text(String(presentCharacters.length))
);
for (const character of presentCharacters) {
$track.append(createAlternatePresentCharacterCard(character));
}
$scroll.append($track);
$panel.empty().append($header, $scroll).show();
syncAlternatePresentCharactersTheme();
}
+10
View File
@@ -11,6 +11,7 @@ import {
$infoBoxContainer, $infoBoxContainer,
$thoughtsContainer, $thoughtsContainer,
$userStatsContainer, $userStatsContainer,
clearThoughtBasedExpressionPortraits,
setPendingDiceRoll, setPendingDiceRoll,
getPendingDiceRoll, getPendingDiceRoll,
clearSessionAvatarPrompts clearSessionAvatarPrompts
@@ -370,6 +371,7 @@ export function setupSettingsPopup() {
// Clear session avatar prompts // Clear session avatar prompts
clearSessionAvatarPrompts(); clearSessionAvatarPrompts();
clearThoughtBasedExpressionPortraits();
// Clear chat metadata immediately (don't wait for debounced save) // Clear chat metadata immediately (don't wait for debounced save)
const context = getContext(); const context = getContext();
@@ -387,6 +389,14 @@ export function setupSettingsPopup() {
delete message.extra.rpg_companion_swipes; delete message.extra.rpg_companion_swipes;
// console.log('[RPG Companion] Cleared swipe data from message at index', i); // 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;
}
}
}
} }
} }
+5
View File
@@ -4,6 +4,7 @@
*/ */
import { extensionSettings, $panelContainer } from '../../core/state.js'; import { extensionSettings, $panelContainer } from '../../core/state.js';
import { syncAlternatePresentCharactersTheme } from './alternatePresentCharacters.js';
/** /**
* Converts hex color and opacity percentage to rgba string * Converts hex color and opacity percentage to rgba string
@@ -96,6 +97,8 @@ export function applyTheme() {
$thoughtPanel.attr('data-theme', theme); $thoughtPanel.attr('data-theme', theme);
} }
} }
syncAlternatePresentCharactersTheme();
} }
/** /**
@@ -150,6 +153,8 @@ export function applyCustomTheme() {
if ($thoughtPanel.length) { if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles); $thoughtPanel.attr('data-theme', 'custom').css(customStyles);
} }
syncAlternatePresentCharactersTheme();
} }
/** /**
+53
View File
@@ -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;
}
+244
View File
@@ -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;
}
+626
View File
@@ -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;
}
@@ -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;
}
+224
View File
@@ -15,6 +15,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
.rpg-panel, .rpg-panel,
#rpg-thought-panel, #rpg-thought-panel,
#rpg-thought-icon, #rpg-thought-icon,
#rpg-alt-present-characters,
.rpg-mobile-toggle { .rpg-mobile-toggle {
--rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9)); --rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9));
--rpg-accent: var(--black30a, rgba(22, 33, 62, 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 */ /* Apply sci-fi theme to thought panel */
#rpg-thought-panel[data-theme="sci-fi"], #rpg-thought-panel[data-theme="sci-fi"],
#rpg-thought-icon[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-mobile-toggle[data-theme="sci-fi"] {
--rpg-bg: #0a0e27; --rpg-bg: #0a0e27;
--rpg-accent: #1a1f3a; --rpg-accent: #1a1f3a;
@@ -3304,6 +3306,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Apply fantasy theme to thought panel */ /* Apply fantasy theme to thought panel */
#rpg-thought-panel[data-theme="fantasy"], #rpg-thought-panel[data-theme="fantasy"],
#rpg-thought-icon[data-theme="fantasy"], #rpg-thought-icon[data-theme="fantasy"],
#rpg-alt-present-characters[data-theme="fantasy"],
.rpg-mobile-toggle[data-theme="fantasy"] { .rpg-mobile-toggle[data-theme="fantasy"] {
--rpg-bg: #2b1810; --rpg-bg: #2b1810;
--rpg-accent: #3d2414; --rpg-accent: #3d2414;
@@ -3361,6 +3364,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
/* Apply cyberpunk theme to thought panel */ /* Apply cyberpunk theme to thought panel */
#rpg-thought-panel[data-theme="cyberpunk"], #rpg-thought-panel[data-theme="cyberpunk"],
#rpg-thought-icon[data-theme="cyberpunk"], #rpg-thought-icon[data-theme="cyberpunk"],
#rpg-alt-present-characters[data-theme="cyberpunk"],
.rpg-mobile-toggle[data-theme="cyberpunk"] { .rpg-mobile-toggle[data-theme="cyberpunk"] {
--rpg-bg: #000000; --rpg-bg: #000000;
--rpg-accent: #0d0d0d; --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 CHAT THOUGHT OVERLAYS
============================================ */ ============================================ */
@@ -11684,3 +11841,70 @@ body:has(.rpg-panel[data-theme="light"]) .rpg-strip-widget {
min-width: 2.5rem !important; min-width: 2.5rem !important;
} }
} }
.rpg-inline-thoughts {
display: flex;
flex-direction: column;
gap: 0.5em;
margin: 0.2em 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;
}
+36
View File
@@ -358,6 +358,33 @@
Display character portraits with their current thoughts and status. Display character portraits with their current thoughts and status.
</small> </small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-alt-present-characters" />
<span data-i18n-key="template.settingsModal.display.showBelowChatPresentCharacters">Show Below-Chat Present Characters</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showBelowChatPresentCharactersNote">
Display a compact Present Characters panel below the chat.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-thought-based-expressions" />
<span data-i18n-key="template.settingsModal.display.thoughtBasedExpressions">Thought-Based Expressions</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="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.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-hide-default-expressions" />
<span data-i18n-key="template.settingsModal.display.hideDefaultExpressionDisplay">Hide Default Expression Display</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.hideDefaultExpressionDisplayNote">
Hide SillyTavern's built-in Character Expressions display.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-thoughts-in-chat" /> <input type="checkbox" id="rpg-toggle-thoughts-in-chat" />
<span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts</span> <span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts</span>
@@ -367,6 +394,15 @@
Display character thoughts as overlay bubbles next to their messages. Display character thoughts as overlay bubbles next to their messages.
</small> </small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-inline-thoughts" />
<span data-i18n-key="template.settingsModal.display.showInlineThoughts">Show Thoughts Below Message Text</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showInlineThoughtsNote">
Switch between the default corner thought bubbles and thought cards below the message text.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-inventory" /> <input type="checkbox" id="rpg-toggle-inventory" />
<span data-i18n-key="template.settingsModal.display.showInventory">Show Inventory</span> <span data-i18n-key="template.settingsModal.display.showInventory">Show Inventory</span>