diff --git a/index.js b/index.js
index af28207..dfba6bb 100644
--- a/index.js
+++ b/index.js
@@ -135,6 +135,14 @@ import {
updateStripWidgets
} from './src/systems/ui/desktop.js';
import { removeAlternatePresentCharactersPanel } from './src/systems/ui/alternatePresentCharacters.js';
+import {
+ initExpressionSync,
+ queueExpressionCaptureForSpeaker,
+ onExpressionSyncSettingChanged,
+ onHideDefaultExpressionDisplaySettingChanged,
+ clearExpressionSyncCache,
+ onExpressionSyncChatChanged
+} from './src/systems/integration/expressionSync.js';
// Feature modules
import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js';
@@ -220,6 +228,7 @@ async function addExtensionSettings() {
clearExtensionPrompts();
updateChatThoughts(); // Remove thought bubbles
cleanupCheckpointUI(); // Remove checkpoint buttons and indicators
+ clearExpressionSyncCache();
// Disable dynamic weather effects
toggleDynamicWeather(false);
@@ -235,6 +244,7 @@ async function addExtensionSettings() {
await initUI();
loadChatData(); // Load chat data for current chat
scheduleChatStateRehydration();
+ initExpressionSync();
updateChatThoughts(); // Create thought bubbles if data exists
injectCheckpointButton(); // Re-add checkpoint buttons
updateAllCheckpointIndicators(); // Update button states
@@ -350,6 +360,18 @@ async function initUI() {
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() {
extensionSettings.showInventory = $(this).prop('checked');
saveSettings();
@@ -1063,6 +1085,8 @@ async function initUI() {
$('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox);
$('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts);
$('#rpg-toggle-alt-present-characters').prop('checked', extensionSettings.showAlternatePresentCharactersPanel ?? false);
+ $('#rpg-toggle-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-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
@@ -1305,6 +1329,7 @@ jQuery(async () => {
try {
loadChatData();
scheduleChatStateRehydration();
+ initExpressionSync();
// Initialize FAB widgets and strip widgets with any loaded data
updateFabWidgets();
updateStripWidgets();
@@ -1382,6 +1407,53 @@ jQuery(async () => {
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar
});
+
+ eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, (messageId) => {
+ if (!extensionSettings.enabled) {
+ return;
+ }
+
+ const renderedMessage = chat[messageId];
+ if (renderedMessage && !renderedMessage.is_user && !renderedMessage.is_system) {
+ 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) {
console.error('[RPG Companion] Event registration failed:', error);
throw error; // This is critical - can't continue without events
diff --git a/src/core/config.js b/src/core/config.js
index 5a1e7ca..1ffb6c9 100644
--- a/src/core/config.js
+++ b/src/core/config.js
@@ -30,6 +30,8 @@ export const defaultSettings = {
showInfoBox: true,
showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
+ syncExpressionsToPresentCharacters: false,
+ hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
diff --git a/src/core/persistence.js b/src/core/persistence.js
index 719c638..339d493 100644
--- a/src/core/persistence.js
+++ b/src/core/persistence.js
@@ -9,10 +9,13 @@ import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
+ syncedExpressionPortraits,
setExtensionSettings,
updateExtensionSettings,
setLastGeneratedData,
setCommittedTrackerData,
+ setSyncedExpressionPortraits,
+ clearSyncedExpressionPortraits,
FEATURE_FLAGS
} from './state.js';
import { migrateInventory } from '../utils/migration.js';
@@ -381,6 +384,16 @@ export function loadSettings() {
settingsChanged = true;
}
+ if (extensionSettings.syncExpressionsToPresentCharacters === undefined) {
+ extensionSettings.syncExpressionsToPresentCharacters = false;
+ settingsChanged = true;
+ }
+
+ if (extensionSettings.hideDefaultExpressionDisplay === undefined) {
+ extensionSettings.hideDefaultExpressionDisplay = false;
+ settingsChanged = true;
+ }
+
// Save migrated settings
if (settingsChanged) {
saveSettings();
@@ -465,6 +478,7 @@ export function saveChatData() {
quests: extensionSettings.quests,
lastGeneratedData: lastGeneratedData,
committedTrackerData: committedTrackerData,
+ syncedExpressionPortraits: syncedExpressionPortraits,
timestamp: Date.now()
};
@@ -548,6 +562,7 @@ export function loadChatData() {
infoBox: null,
characterThoughts: null
});
+ clearSyncedExpressionPortraits();
}
// Restore stats
@@ -596,6 +611,12 @@ export function loadChatData() {
// 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
if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) {
const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
diff --git a/src/core/state.js b/src/core/state.js
index 58ae860..050a8fa 100644
--- a/src/core/state.js
+++ b/src/core/state.js
@@ -19,6 +19,8 @@ export let extensionSettings = {
showInfoBox: true,
showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
+ syncExpressionsToPresentCharacters: false,
+ hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
@@ -361,6 +363,40 @@ export function clearSessionAvatarPrompts() {
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)
* Used to determine whether to commit lastGeneratedData to committedTrackerData
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c2a77b5..8f3a16e 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -36,6 +36,10 @@
"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.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.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 5e53570..bc132c6 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -37,6 +37,10 @@
"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.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.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",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 1563159..e449647 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -36,6 +36,10 @@
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
"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.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь",
diff --git a/src/i18n/zh-tw.json b/src/i18n/zh-tw.json
index 56e75d8..62bc936 100644
--- a/src/i18n/zh-tw.json
+++ b/src/i18n/zh-tw.json
@@ -32,6 +32,10 @@
"template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
"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.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
diff --git a/src/systems/integration/expressionSync.js b/src/systems/integration/expressionSync.js
new file mode 100644
index 0000000..acf7a60
--- /dev/null
+++ b/src/systems/integration/expressionSync.js
@@ -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();
+}
diff --git a/src/systems/ui/alternatePresentCharacters.js b/src/systems/ui/alternatePresentCharacters.js
index 2c75d76..34969ef 100644
--- a/src/systems/ui/alternatePresentCharacters.js
+++ b/src/systems/ui/alternatePresentCharacters.js
@@ -1,5 +1,6 @@
import { extensionSettings } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
+import { getExpressionPortraitForCharacter } from '../integration/expressionSync.js';
import {
getPresentCharactersTrackerData,
parsePresentCharacters,
@@ -132,7 +133,9 @@ export function renderAlternatePresentCharacters({ useCommittedFallback = true }
`;
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 || '');
html += `
diff --git a/src/systems/ui/modals.js b/src/systems/ui/modals.js
index ae051bc..c51c4a3 100644
--- a/src/systems/ui/modals.js
+++ b/src/systems/ui/modals.js
@@ -11,6 +11,7 @@ import {
$infoBoxContainer,
$thoughtsContainer,
$userStatsContainer,
+ clearSyncedExpressionPortraits,
setPendingDiceRoll,
getPendingDiceRoll,
clearSessionAvatarPrompts
@@ -370,6 +371,7 @@ export function setupSettingsPopup() {
// Clear session avatar prompts
clearSessionAvatarPrompts();
+ clearSyncedExpressionPortraits();
// Clear chat metadata immediately (don't wait for debounced save)
const context = getContext();
diff --git a/template.html b/template.html
index bfc071c..7a49a1b 100644
--- a/template.html
+++ b/template.html
@@ -367,6 +367,24 @@
Display a compact Present Characters panel below the chat.
+
+
+ Use each character's current SillyTavern expression portrait in the below-chat Present Characters panel.
+
+
+
+
+ Hide SillyTavern's built-in Character Expressions display.
+
+