First attempt at adding expression sync
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показывать инвентарь",
|
||||
|
||||
@@ -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 修改它們。",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 += `
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -367,6 +367,24 @@
|
||||
Display a compact Present Characters panel below the chat.
|
||||
</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">
|
||||
<input type="checkbox" id="rpg-toggle-thoughts-in-chat" />
|
||||
<span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts</span>
|
||||
|
||||
Reference in New Issue
Block a user