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. + +