First attempt at adding expression sync

This commit is contained in:
Tremendoussly
2026-03-08 22:29:14 +01:00
parent 2f98686e60
commit c73c0c2bb6
12 changed files with 803 additions and 1 deletions
+72
View File
@@ -135,6 +135,14 @@ import {
updateStripWidgets updateStripWidgets
} from './src/systems/ui/desktop.js'; } from './src/systems/ui/desktop.js';
import { removeAlternatePresentCharactersPanel } from './src/systems/ui/alternatePresentCharacters.js'; import { removeAlternatePresentCharactersPanel } from './src/systems/ui/alternatePresentCharacters.js';
import {
initExpressionSync,
queueExpressionCaptureForSpeaker,
onExpressionSyncSettingChanged,
onHideDefaultExpressionDisplaySettingChanged,
clearExpressionSyncCache,
onExpressionSyncChatChanged
} from './src/systems/integration/expressionSync.js';
// Feature modules // Feature modules
import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js'; import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js';
@@ -220,6 +228,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
clearExpressionSyncCache();
// Disable dynamic weather effects // Disable dynamic weather effects
toggleDynamicWeather(false); toggleDynamicWeather(false);
@@ -235,6 +244,7 @@ async function addExtensionSettings() {
await initUI(); await initUI();
loadChatData(); // Load chat data for current chat loadChatData(); // Load chat data for current chat
scheduleChatStateRehydration(); scheduleChatStateRehydration();
initExpressionSync();
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
@@ -350,6 +360,18 @@ async function initUI() {
renderThoughts(); renderThoughts();
}); });
$('#rpg-toggle-sync-expressions').on('change', function() {
extensionSettings.syncExpressionsToPresentCharacters = $(this).prop('checked');
saveSettings();
onExpressionSyncSettingChanged(extensionSettings.syncExpressionsToPresentCharacters);
});
$('#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() {
extensionSettings.showInventory = $(this).prop('checked'); extensionSettings.showInventory = $(this).prop('checked');
saveSettings(); saveSettings();
@@ -1063,6 +1085,8 @@ async function initUI() {
$('#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-alt-present-characters').prop('checked', extensionSettings.showAlternatePresentCharactersPanel ?? false);
$('#rpg-toggle-sync-expressions').prop('checked', extensionSettings.syncExpressionsToPresentCharacters === 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);
@@ -1305,6 +1329,7 @@ jQuery(async () => {
try { try {
loadChatData(); loadChatData();
scheduleChatStateRehydration(); scheduleChatStateRehydration();
initExpressionSync();
// Initialize FAB widgets and strip widgets with any loaded data // Initialize FAB widgets and strip widgets with any loaded data
updateFabWidgets(); updateFabWidgets();
updateStripWidgets(); updateStripWidgets();
@@ -1382,6 +1407,53 @@ jQuery(async () => {
[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) {
queueExpressionCaptureForSpeaker(renderedMessage.name);
}
});
eventSource.on(event_types.MESSAGE_UPDATED, (messageId) => {
if (!extensionSettings.enabled) {
return;
}
const updatedMessage = chat[messageId];
if (updatedMessage && !updatedMessage.is_user && !updatedMessage.is_system) {
queueExpressionCaptureForSpeaker(updatedMessage.name);
}
});
eventSource.on(event_types.MESSAGE_SWIPED, (messageIndex) => {
if (!extensionSettings.enabled) {
return;
}
const swipedMessage = chat[messageIndex];
if (swipedMessage && !swipedMessage.is_user && !swipedMessage.is_system) {
queueExpressionCaptureForSpeaker(swipedMessage.name);
}
});
eventSource.on(event_types.CHAT_CHANGED, () => {
clearExpressionSyncCache();
setTimeout(() => onExpressionSyncChatChanged(), 0);
});
eventSource.on(event_types.MESSAGE_DELETED, () => {
if (!extensionSettings.enabled) {
return;
}
clearExpressionSyncCache();
setTimeout(() => onExpressionSyncChatChanged(), 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
+2
View File
@@ -30,6 +30,8 @@ export const defaultSettings = {
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false, showAlternatePresentCharactersPanel: false,
syncExpressionsToPresentCharacters: 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
+21
View File
@@ -9,10 +9,13 @@ import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
committedTrackerData, committedTrackerData,
syncedExpressionPortraits,
setExtensionSettings, setExtensionSettings,
updateExtensionSettings, updateExtensionSettings,
setLastGeneratedData, setLastGeneratedData,
setCommittedTrackerData, setCommittedTrackerData,
setSyncedExpressionPortraits,
clearSyncedExpressionPortraits,
FEATURE_FLAGS FEATURE_FLAGS
} from './state.js'; } from './state.js';
import { migrateInventory } from '../utils/migration.js'; import { migrateInventory } from '../utils/migration.js';
@@ -381,6 +384,16 @@ export function loadSettings() {
settingsChanged = true; settingsChanged = true;
} }
if (extensionSettings.syncExpressionsToPresentCharacters === undefined) {
extensionSettings.syncExpressionsToPresentCharacters = false;
settingsChanged = true;
}
if (extensionSettings.hideDefaultExpressionDisplay === undefined) {
extensionSettings.hideDefaultExpressionDisplay = false;
settingsChanged = true;
}
// Save migrated settings // Save migrated settings
if (settingsChanged) { if (settingsChanged) {
saveSettings(); saveSettings();
@@ -465,6 +478,7 @@ export function saveChatData() {
quests: extensionSettings.quests, quests: extensionSettings.quests,
lastGeneratedData: lastGeneratedData, lastGeneratedData: lastGeneratedData,
committedTrackerData: committedTrackerData, committedTrackerData: committedTrackerData,
syncedExpressionPortraits: syncedExpressionPortraits,
timestamp: Date.now() timestamp: Date.now()
}; };
@@ -548,6 +562,7 @@ export function loadChatData() {
infoBox: null, infoBox: null,
characterThoughts: null characterThoughts: null
}); });
clearSyncedExpressionPortraits();
} }
// Restore stats // Restore stats
@@ -596,6 +611,12 @@ export function loadChatData() {
// console.log('[RPG Companion] ⚠️ No lastGeneratedData found in save'); // console.log('[RPG Companion] ⚠️ No lastGeneratedData found in save');
} }
if (savedData?.syncedExpressionPortraits && typeof savedData.syncedExpressionPortraits === 'object') {
setSyncedExpressionPortraits(savedData.syncedExpressionPortraits);
} else {
clearSyncedExpressionPortraits();
}
// 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);
+36
View File
@@ -19,6 +19,8 @@ export let extensionSettings = {
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false, showAlternatePresentCharactersPanel: false,
syncExpressionsToPresentCharacters: 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
@@ -361,6 +363,40 @@ export function clearSessionAvatarPrompts() {
sessionAvatarPrompts = {}; sessionAvatarPrompts = {};
} }
/**
* Per-chat storage for synced Character Expressions portraits.
* Maps normalized character names to the last captured expression image URL.
*/
export let syncedExpressionPortraits = {};
export function setSyncedExpressionPortrait(characterName, src) {
if (!characterName || !src) {
return;
}
syncedExpressionPortraits[characterName] = src;
}
export function removeSyncedExpressionPortrait(characterName) {
if (!characterName) {
return;
}
delete syncedExpressionPortraits[characterName];
}
export function setSyncedExpressionPortraits(portraits) {
syncedExpressionPortraits = portraits && typeof portraits === 'object' ? { ...portraits } : {};
}
export function getSyncedExpressionPortrait(characterName) {
return syncedExpressionPortraits[characterName] || null;
}
export function clearSyncedExpressionPortraits() {
syncedExpressionPortraits = {};
}
/** /**
* 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
+4
View File
@@ -36,6 +36,10 @@
"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.showBelowChatPresentCharacters": "Show Below-Chat Present Characters",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Display a compact Present Characters panel below the chat.", "template.settingsModal.display.showBelowChatPresentCharactersNote": "Display a compact Present Characters panel below the chat.",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressions": "Sync Expressions in Below-Chat Panel",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Use each character's current SillyTavern expression portrait in the below-chat Present Characters panel.",
"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",
+4
View File
@@ -37,6 +37,10 @@
"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.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.showBelowChatPresentCharactersNote": "Afficher un panneau compact des personnages présents sous le chat.",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressions": "Sync Expressions in Below-Chat Panel",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Use each character's current SillyTavern expression portrait in the below-chat Present Characters panel.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Hide Default Expression Display",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Hide SillyTavern's built-in Character Expressions display.",
"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",
+4
View File
@@ -36,6 +36,10 @@
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.", "template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом", "template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.", "template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressions": "Sync Expressions in Below-Chat Panel",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Use each character's current SillyTavern expression portrait in the below-chat Present Characters panel.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Hide Default Expression Display",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Hide SillyTavern's built-in Character Expressions display.",
"template.settingsModal.display.narratorMode": "Режим расказчика", "template.settingsModal.display.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.", "template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь", "template.settingsModal.display.showInventory": "Показывать инвентарь",
+4
View File
@@ -32,6 +32,10 @@
"template.settingsModal.display.showPresentCharacters": "顯示在場角色", "template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色", "template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。", "template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressions": "Sync Expressions in Below-Chat Panel",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Use each character's current SillyTavern expression portrait in the below-chat Present Characters panel.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Hide Default Expression Display",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Hide SillyTavern's built-in Character Expressions display.",
"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 修改它們。",
+632
View File
@@ -0,0 +1,632 @@
/**
* Character Expressions -> below-chat Present Characters portrait sync.
*
* Mirrors SillyTavern's currently displayed Character Expressions image into
* the alternate Present Characters panel, persisting the last known
* expression for each character until they speak again.
*/
import { chat } from '../../../../../../../script.js';
import {
extensionSettings,
syncedExpressionPortraits,
setSyncedExpressionPortrait,
getSyncedExpressionPortrait,
removeSyncedExpressionPortrait
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
let expressionContainerObserver = null;
let expressionImageObserver = null;
let observedExpressionImage = null;
let pendingSpeakerName = null;
let pendingSpeakerBaselineSignature = null;
let pendingSpeakerQueuedAt = 0;
let lastCapturedExpressionSrc = null;
let scheduledCaptureTimers = [];
let hiddenExpressionStyleElement = null;
let pendingCaptureRequestId = 0;
function normalizeName(name) {
return String(name || '').trim().toLowerCase();
}
function normalizeExpressionSrc(src) {
return String(src || '').trim();
}
function resolveExpressionUrl(src) {
const normalized = normalizeExpressionSrc(src);
if (!normalized) {
return null;
}
try {
return new URL(normalized, window.location.href);
} catch {
return null;
}
}
function isDocumentLikeUrl(src) {
const candidate = resolveExpressionUrl(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;
}
function isUsableExpressionSrc(src) {
const normalized = normalizeExpressionSrc(src);
if (!normalized) {
return false;
}
const lower = normalized.toLowerCase();
if (lower.includes('/img/default-expressions/') || lower.includes('/default-expressions/')) {
return false;
}
if (isDocumentLikeUrl(normalized)) {
return false;
}
return true;
}
function purgeInvalidSyncedExpressionPortraits() {
let changed = false;
for (const [storedName, src] of Object.entries(syncedExpressionPortraits)) {
if (!isUsableExpressionSrc(src)) {
removeSyncedExpressionPortrait(storedName);
changed = true;
}
}
if (changed) {
saveChatData();
}
return changed;
}
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 + ' ');
}
export function getExpressionPortraitForCharacter(characterName) {
const target = normalizeName(characterName);
if (!target) {
return null;
}
const exact = getSyncedExpressionPortrait(target);
if (isUsableExpressionSrc(exact)) {
return exact;
}
for (const [storedName, src] of Object.entries(syncedExpressionPortraits)) {
if (namesMatch(storedName, target) && isUsableExpressionSrc(src)) {
return src;
}
}
return null;
}
function getLatestAssistantSpeakerName() {
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message || message.is_user || message.is_system) {
continue;
}
return message.name || null;
}
return null;
}
function shouldHideNativeExpressionDisplay() {
return extensionSettings.enabled === true && extensionSettings.hideDefaultExpressionDisplay === true;
}
function shouldRunExpressionObservers() {
return extensionSettings.enabled === true && (
extensionSettings.syncExpressionsToPresentCharacters === true
|| extensionSettings.hideDefaultExpressionDisplay === true
);
}
function isExpressionContainerNode(node) {
if (!(node instanceof Element)) {
return false;
}
return !!node.closest('#expression-wrapper, #expression-holder, .expression-holder, [data-expression-container], #visual-novel-wrapper');
}
function getExpressionImageState(img) {
if (!(img instanceof HTMLImageElement)) {
return null;
}
const rawSrc = normalizeExpressionSrc(img.getAttribute('src'));
const resolvedSrc = normalizeExpressionSrc(img.currentSrc || img.src || '');
const src = rawSrc || '';
const spriteFolderName = String(img.getAttribute('data-sprite-folder-name') || '').trim();
const spriteFileName = String(img.getAttribute('data-sprite-filename') || '').trim();
const expression = String(img.getAttribute('data-expression') || img.getAttribute('title') || '').trim();
const isDefault = img.classList.contains('default')
|| rawSrc.toLowerCase().includes('/img/default-expressions/')
|| resolvedSrc.toLowerCase().includes('/img/default-expressions/');
return {
src,
resolvedSrc,
spriteFolderName,
spriteFileName,
expression,
isDefault,
signature: JSON.stringify({
src,
resolvedSrc,
spriteFolderName,
spriteFileName,
expression,
isDefault
})
};
}
function hasMeaningfulMetadataValue(value) {
const normalized = String(value || '').trim().toLowerCase();
return Boolean(normalized && normalized !== 'null' && normalized !== 'undefined');
}
function looksLikeFallbackExpressionAsset(state) {
if (!state) {
return true;
}
const combined = [state.src, state.spriteFolderName, state.spriteFileName, state.expression]
.map(value => String(value || '').trim().toLowerCase())
.join(' ');
return [
'/img/default-expressions/',
'/default-expressions/',
'/emote/',
'/emotes/',
'/emoji/',
'/emotion/',
'/emotions/',
' default ',
' fallback ',
' placeholder '
].some(fragment => combined.includes(fragment.trim()));
}
function hasRealSyncedSprite(state) {
if (!state) {
return false;
}
if (!state.src || state.isDefault) {
return false;
}
if (!isUsableExpressionSrc(state.src)) {
return false;
}
if (!hasMeaningfulMetadataValue(state.spriteFolderName)) {
return false;
}
if (!hasMeaningfulMetadataValue(state.spriteFileName)) {
return false;
}
if (!hasMeaningfulMetadataValue(state.expression)) {
return false;
}
if (looksLikeFallbackExpressionAsset(state)) {
return false;
}
return true;
}
function isProbablyExpressionImage(img) {
if (!(img instanceof HTMLImageElement)) {
return false;
}
const state = getExpressionImageState(img);
if (!state?.src) {
return false;
}
const hasExpressionClass = img.classList.contains('expression') || img.id === 'expression-image';
const hasExpressionMetadata = Boolean(state.expression || state.spriteFolderName || state.spriteFileName);
if (!hasExpressionClass && !hasExpressionMetadata && !isExpressionContainerNode(img)) {
return false;
}
return true;
}
function getPreferredVisualNovelImage(speakerName) {
const target = normalizeName(speakerName);
const candidates = Array.from(document.querySelectorAll('#visual-novel-wrapper .expression-holder img'))
.filter(node => isProbablyExpressionImage(node) && hasRealSyncedSprite(getExpressionImageState(node)));
if (!candidates.length) {
return null;
}
if (!target) {
return candidates.find(node => node.offsetParent !== null) || candidates[0] || null;
}
const exactMatch = candidates.find(node => {
const state = getExpressionImageState(node);
const folderRoot = String(state?.spriteFolderName || '').split('/')[0];
return namesMatch(folderRoot, target);
});
if (exactMatch) {
return exactMatch;
}
return candidates.find(node => node.offsetParent !== null) || candidates[0] || null;
}
function findExpressionImageElement(speakerName = null) {
if (observedExpressionImage && observedExpressionImage.isConnected && isProbablyExpressionImage(observedExpressionImage)) {
return observedExpressionImage;
}
const preferredSelectors = [
'#expression-wrapper img.expression',
'#expression-holder > img.expression',
'#expression-image'
];
for (const selector of preferredSelectors) {
const nodes = Array.from(document.querySelectorAll(selector));
const visibleMatch = nodes.find(node => isProbablyExpressionImage(node)
&& hasRealSyncedSprite(getExpressionImageState(node))
&& node.offsetParent !== null);
if (visibleMatch) {
return visibleMatch;
}
const anyRealMatch = nodes.find(node =>
isProbablyExpressionImage(node) && hasRealSyncedSprite(getExpressionImageState(node)));
if (anyRealMatch) {
return anyRealMatch;
}
}
const visualNovelMatch = getPreferredVisualNovelImage(speakerName);
if (visualNovelMatch) {
return visualNovelMatch;
}
const allImages = Array.from(document.querySelectorAll('img.expression, #visual-novel-wrapper .expression-holder img'));
return allImages.find(node => isProbablyExpressionImage(node) && hasRealSyncedSprite(getExpressionImageState(node))) || null;
}
function refreshExpressionConsumers() {
renderAlternatePresentCharacters({ useCommittedFallback: true });
}
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 syncNativeExpressionDisplayVisibility() {
if (shouldHideNativeExpressionDisplay()) {
hideNativeExpressionDisplay();
} else {
showNativeExpressionDisplay();
}
}
function teardownExpressionObservers() {
if (expressionContainerObserver) {
expressionContainerObserver.disconnect();
expressionContainerObserver = null;
}
if (expressionImageObserver) {
expressionImageObserver.disconnect();
expressionImageObserver = null;
}
observedExpressionImage = null;
}
function captureExpressionForSpeaker(speakerName, expectedRequestId = null) {
if (!extensionSettings.enabled || !extensionSettings.syncExpressionsToPresentCharacters) {
return false;
}
if (expectedRequestId !== null && expectedRequestId !== pendingCaptureRequestId) {
return false;
}
const name = normalizeName(speakerName || pendingSpeakerName || getLatestAssistantSpeakerName());
if (!name) {
return false;
}
const previous = getSyncedExpressionPortrait(name);
const img = findExpressionImageElement(name);
const state = getExpressionImageState(img);
if (!hasRealSyncedSprite(state)) {
const elapsed = pendingSpeakerQueuedAt ? (Date.now() - pendingSpeakerQueuedAt) : 0;
if (previous && elapsed >= 1200) {
removeSyncedExpressionPortrait(name);
saveChatData();
refreshExpressionConsumers();
}
return false;
}
pendingSpeakerName = name;
// After a speaker switch, SillyTavern may briefly keep showing the previous
// speaker's expression. Wait for the widget to actually change before storing.
if (pendingSpeakerBaselineSignature && state.signature === pendingSpeakerBaselineSignature && previous !== state.src) {
return false;
}
if (previous === state.src && lastCapturedExpressionSrc === state.src) {
return true;
}
lastCapturedExpressionSrc = state.src;
pendingSpeakerBaselineSignature = null;
setSyncedExpressionPortrait(name, state.src);
saveChatData();
refreshExpressionConsumers();
return true;
}
function observeExpressionImage(img) {
if (!shouldRunExpressionObservers()) {
return;
}
if (!img || observedExpressionImage === img) {
return;
}
if (expressionImageObserver) {
expressionImageObserver.disconnect();
}
observedExpressionImage = img;
expressionImageObserver = new MutationObserver(() => {
captureExpressionForSpeaker(pendingSpeakerName, pendingCaptureRequestId);
});
expressionImageObserver.observe(img, {
attributes: true,
attributeFilter: ['src', 'class', 'style', 'title', 'data-expression', 'data-sprite-folder-name', 'data-sprite-filename']
});
}
function ensureExpressionObservers() {
syncNativeExpressionDisplayVisibility();
if (!shouldRunExpressionObservers()) {
teardownExpressionObservers();
return;
}
const currentImg = findExpressionImageElement(pendingSpeakerName);
if (currentImg) {
observeExpressionImage(currentImg);
} else if (expressionImageObserver) {
expressionImageObserver.disconnect();
expressionImageObserver = null;
observedExpressionImage = null;
}
if (expressionContainerObserver) {
return;
}
expressionContainerObserver = new MutationObserver(() => {
if (!shouldRunExpressionObservers()) {
teardownExpressionObservers();
syncNativeExpressionDisplayVisibility();
return;
}
const img = findExpressionImageElement(pendingSpeakerName);
if (img) {
observeExpressionImage(img);
captureExpressionForSpeaker(pendingSpeakerName, pendingCaptureRequestId);
} else if (expressionImageObserver) {
expressionImageObserver.disconnect();
expressionImageObserver = null;
observedExpressionImage = null;
}
syncNativeExpressionDisplayVisibility();
});
expressionContainerObserver.observe(document.body, {
childList: true,
subtree: true
});
}
function clearScheduledCaptures() {
for (const timer of scheduledCaptureTimers) {
clearTimeout(timer);
}
scheduledCaptureTimers = [];
}
export function queueExpressionCaptureForSpeaker(speakerName) {
if (!extensionSettings.enabled || !extensionSettings.syncExpressionsToPresentCharacters) {
return;
}
pendingSpeakerName = normalizeName(speakerName || getLatestAssistantSpeakerName());
if (!pendingSpeakerName) {
return;
}
const currentImg = findExpressionImageElement(pendingSpeakerName);
const currentState = getExpressionImageState(currentImg);
pendingSpeakerBaselineSignature = currentState?.signature || null;
pendingSpeakerQueuedAt = Date.now();
pendingCaptureRequestId += 1;
const requestId = pendingCaptureRequestId;
ensureExpressionObservers();
clearScheduledCaptures();
for (const delay of [50, 200, 500, 900, 1500, 2200]) {
const timer = setTimeout(() => captureExpressionForSpeaker(pendingSpeakerName, requestId), delay);
scheduledCaptureTimers.push(timer);
}
}
export function syncExpressionFromLatestMessage() {
if (!extensionSettings.enabled || !extensionSettings.syncExpressionsToPresentCharacters) {
return;
}
queueExpressionCaptureForSpeaker(getLatestAssistantSpeakerName());
}
export function initExpressionSync() {
if (purgeInvalidSyncedExpressionPortraits()) {
refreshExpressionConsumers();
}
ensureExpressionObservers();
if (extensionSettings.syncExpressionsToPresentCharacters) {
syncExpressionFromLatestMessage();
}
}
export function onExpressionSyncChatChanged() {
if (!extensionSettings.enabled) {
showNativeExpressionDisplay();
return;
}
const purged = purgeInvalidSyncedExpressionPortraits();
if (purged) {
refreshExpressionConsumers();
}
const retryDelays = [0, 80, 220, 500];
for (const delay of retryDelays) {
setTimeout(() => {
ensureExpressionObservers();
syncNativeExpressionDisplayVisibility();
if (extensionSettings.syncExpressionsToPresentCharacters) {
syncExpressionFromLatestMessage();
} else {
refreshExpressionConsumers();
}
}, delay);
}
}
export function onExpressionSyncSettingChanged(enabled) {
if (enabled) {
const purged = purgeInvalidSyncedExpressionPortraits();
initExpressionSync();
if (!purged) {
refreshExpressionConsumers();
}
syncExpressionFromLatestMessage();
return;
}
ensureExpressionObservers();
clearScheduledCaptures();
pendingCaptureRequestId += 1;
pendingSpeakerName = null;
pendingSpeakerBaselineSignature = null;
pendingSpeakerQueuedAt = 0;
lastCapturedExpressionSrc = null;
refreshExpressionConsumers();
}
export function onHideDefaultExpressionDisplaySettingChanged(enabled) {
extensionSettings.hideDefaultExpressionDisplay = enabled === true;
ensureExpressionObservers();
syncNativeExpressionDisplayVisibility();
setTimeout(() => syncNativeExpressionDisplayVisibility(), 0);
setTimeout(() => syncNativeExpressionDisplayVisibility(), 120);
}
export function clearExpressionSyncCache() {
clearScheduledCaptures();
pendingCaptureRequestId += 1;
pendingSpeakerName = null;
pendingSpeakerBaselineSignature = null;
pendingSpeakerQueuedAt = 0;
lastCapturedExpressionSrc = null;
teardownExpressionObservers();
showNativeExpressionDisplay();
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { extensionSettings } from '../../core/state.js'; import { extensionSettings } from '../../core/state.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { getExpressionPortraitForCharacter } from '../integration/expressionSync.js';
import { import {
getPresentCharactersTrackerData, getPresentCharactersTrackerData,
parsePresentCharacters, parsePresentCharacters,
@@ -132,7 +133,9 @@ export function renderAlternatePresentCharacters({ useCommittedFallback = true }
`; `;
for (const character of presentCharacters) { for (const character of presentCharacters) {
const portrait = resolvePresentCharacterPortrait(character.name); const portrait = (extensionSettings.syncExpressionsToPresentCharacters
? getExpressionPortraitForCharacter(character.name)
: null) || resolvePresentCharacterPortrait(character.name);
const name = escapeHtml(character.name || ''); const name = escapeHtml(character.name || '');
html += ` html += `
+2
View File
@@ -11,6 +11,7 @@ import {
$infoBoxContainer, $infoBoxContainer,
$thoughtsContainer, $thoughtsContainer,
$userStatsContainer, $userStatsContainer,
clearSyncedExpressionPortraits,
setPendingDiceRoll, setPendingDiceRoll,
getPendingDiceRoll, getPendingDiceRoll,
clearSessionAvatarPrompts clearSessionAvatarPrompts
@@ -370,6 +371,7 @@ export function setupSettingsPopup() {
// Clear session avatar prompts // Clear session avatar prompts
clearSessionAvatarPrompts(); clearSessionAvatarPrompts();
clearSyncedExpressionPortraits();
// 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();
+18
View File
@@ -367,6 +367,24 @@
Display a compact Present Characters panel below the chat. Display a compact Present Characters panel below the chat.
</small> </small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-sync-expressions" />
<span data-i18n-key="template.settingsModal.display.syncBelowChatPresentCharactersExpressions">Sync Expressions in Below-Chat Panel</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote">
Use each character's current SillyTavern expression portrait in the below-chat Present Characters panel.
</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>