Rename expression sync to thought-based expression portraits and clean up compatibility solutions
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Thought-based Character Expressions for the below-chat Present Characters panel.
|
||||
*
|
||||
* Derives portrait expressions from the current Present Characters thoughts
|
||||
* payload, while keeping SillyTavern's native Character Expressions widget
|
||||
* independent from the below-chat panel.
|
||||
*/
|
||||
|
||||
import { getContext } from '../../../../../../extensions.js';
|
||||
import {
|
||||
extensionSettings,
|
||||
thoughtBasedExpressionPortraits,
|
||||
setThoughtBasedExpressionPortraits
|
||||
} from '../../core/state.js';
|
||||
import {
|
||||
getCurrentMessageSwipeTrackerData,
|
||||
saveChatData,
|
||||
setMessageSwipeTrackerField
|
||||
} from '../../core/persistence.js';
|
||||
import { isUsableThoughtBasedExpressionSrc } from '../../utils/thoughtBasedExpressionPortraits.js';
|
||||
import {
|
||||
getPresentCharactersTrackerData,
|
||||
parsePresentCharacters
|
||||
} from '../../utils/presentCharacters.js';
|
||||
import {
|
||||
classifyExpressionText,
|
||||
clearExpressionsCompatibilityCache,
|
||||
getExpressionClassificationSettingsSignature,
|
||||
getExpressionPortraitSettingsSignature,
|
||||
getExpressionsSettingsSignature,
|
||||
isExpressionsExtensionEnabled,
|
||||
resolveSpriteFolderNameForCharacter,
|
||||
resolveExpressionPortraitForCharacter
|
||||
} from '../../utils/sillyTavernExpressions.js';
|
||||
|
||||
const OFF_SCENE_THOUGHT_PATTERN = /\b(not\s+(currently\s+)?(in|at|present|in\s+the)\s+(the\s+)?(scene|area|room|location|vicinity))\b|\b(off[\s-]?scene)\b|\b(not\s+present)\b|\b(absent)\b|\b(away\s+from\s+(the\s+)?scene)\b/i;
|
||||
const CHAT_CHANGE_RETRY_DELAYS = [0, 80, 220, 500];
|
||||
const REFRESH_DEBOUNCE_DELAY = 80;
|
||||
const THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION = 1;
|
||||
const THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD = 'thoughtBasedExpressions';
|
||||
|
||||
let hiddenExpressionStyleElement = null;
|
||||
let thoughtBasedExpressionsRefreshHandler = null;
|
||||
let scheduledRefreshTimer = null;
|
||||
let activeRefreshRunId = 0;
|
||||
let lastCompletedRefreshSignature = null;
|
||||
let lastExpressionSettingsSignature = null;
|
||||
|
||||
function normalizeName(name) {
|
||||
return String(name || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function shouldHideNativeExpressionDisplay() {
|
||||
return extensionSettings.enabled === true && extensionSettings.hideDefaultExpressionDisplay === true;
|
||||
}
|
||||
|
||||
function shouldUseThoughtBasedExpressions() {
|
||||
return extensionSettings.enabled === true
|
||||
&& extensionSettings.enableThoughtBasedExpressions === true
|
||||
&& extensionSettings.showAlternatePresentCharactersPanel === true;
|
||||
}
|
||||
|
||||
function notifyThoughtBasedExpressionsConsumers() {
|
||||
thoughtBasedExpressionsRefreshHandler?.();
|
||||
}
|
||||
|
||||
function getHideStyleCss() {
|
||||
return `
|
||||
#expression-image,
|
||||
#expression-holder,
|
||||
.expression-holder,
|
||||
[data-expression-container],
|
||||
#expression-image img,
|
||||
#expression-holder img,
|
||||
.expression-holder img,
|
||||
[data-expression-container] img {
|
||||
position: absolute !important;
|
||||
left: -10000px !important;
|
||||
top: 0 !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function hideNativeExpressionDisplay() {
|
||||
if (hiddenExpressionStyleElement?.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.id = 'rpg-hidden-native-expression-display-style';
|
||||
styleElement.textContent = getHideStyleCss();
|
||||
document.head.appendChild(styleElement);
|
||||
hiddenExpressionStyleElement = styleElement;
|
||||
}
|
||||
|
||||
function showNativeExpressionDisplay() {
|
||||
if (hiddenExpressionStyleElement?.isConnected) {
|
||||
hiddenExpressionStyleElement.remove();
|
||||
} else {
|
||||
document.getElementById('rpg-hidden-native-expression-display-style')?.remove();
|
||||
}
|
||||
|
||||
hiddenExpressionStyleElement = null;
|
||||
}
|
||||
|
||||
function updateNativeExpressionDisplayVisibility() {
|
||||
if (shouldHideNativeExpressionDisplay()) {
|
||||
hideNativeExpressionDisplay();
|
||||
} else {
|
||||
showNativeExpressionDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function clearScheduledRefresh() {
|
||||
if (scheduledRefreshTimer !== null) {
|
||||
clearTimeout(scheduledRefreshTimer);
|
||||
scheduledRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(item => stableStringify(item)).join(',')}]`;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function normalizeThoughtPayload(payload) {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof payload === 'object') {
|
||||
return stableStringify(payload);
|
||||
}
|
||||
|
||||
if (typeof payload !== 'string') {
|
||||
return String(payload);
|
||||
}
|
||||
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return stableStringify(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return trimmed.replace(/\r\n/g, '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExpressionLabel(label) {
|
||||
return String(label || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function arePortraitMapsEqual(left, right) {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return leftKeys.every(key => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function applyThoughtBasedExpressionPortraits(nextPortraits) {
|
||||
if (arePortraitMapsEqual(thoughtBasedExpressionPortraits, nextPortraits)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setThoughtBasedExpressionPortraits(nextPortraits);
|
||||
return true;
|
||||
}
|
||||
|
||||
function purgeInvalidThoughtBasedExpressionPortraits() {
|
||||
const nextPortraits = {};
|
||||
|
||||
for (const [characterName, src] of Object.entries(thoughtBasedExpressionPortraits)) {
|
||||
if (isUsableThoughtBasedExpressionSrc(src)) {
|
||||
nextPortraits[characterName] = src;
|
||||
}
|
||||
}
|
||||
|
||||
return applyThoughtBasedExpressionPortraits(nextPortraits);
|
||||
}
|
||||
|
||||
function getMessageThoughtPayload(message) {
|
||||
if (!message || message.is_user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const swipeData = getCurrentMessageSwipeTrackerData(message);
|
||||
return normalizeThoughtPayload(swipeData?.characterThoughts ?? null);
|
||||
}
|
||||
|
||||
function findThoughtSourceMessageInfo(characterThoughtsData) {
|
||||
const chatMessages = getContext()?.chat || [];
|
||||
const currentThoughts = normalizeThoughtPayload(characterThoughtsData);
|
||||
let fallback = null;
|
||||
|
||||
for (let i = chatMessages.length - 1; i >= 0; i--) {
|
||||
const message = chatMessages[i];
|
||||
if (!message || message.is_user || message.is_system) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const swipeData = getCurrentMessageSwipeTrackerData(message);
|
||||
if (!swipeData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceInfo = {
|
||||
message,
|
||||
messageIndex: i,
|
||||
swipeId: Number(message.swipe_id ?? 0),
|
||||
swipeData
|
||||
};
|
||||
|
||||
if (!fallback) {
|
||||
fallback = sourceInfo;
|
||||
}
|
||||
|
||||
const messageThoughts = getMessageThoughtPayload(message);
|
||||
if (currentThoughts && messageThoughts === currentThoughts) {
|
||||
return sourceInfo;
|
||||
}
|
||||
}
|
||||
|
||||
return currentThoughts ? null : fallback;
|
||||
}
|
||||
|
||||
function isThoughtBasedExpressionsCache(candidate) {
|
||||
return !!(
|
||||
candidate
|
||||
&& typeof candidate === 'object'
|
||||
&& !Array.isArray(candidate)
|
||||
&& candidate.version === THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION
|
||||
&& candidate.entries
|
||||
&& typeof candidate.entries === 'object'
|
||||
&& !Array.isArray(candidate.entries)
|
||||
);
|
||||
}
|
||||
|
||||
function getSwipeThoughtBasedExpressionsCache(sourceInfo) {
|
||||
const directCache = sourceInfo?.swipeData?.[THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD];
|
||||
return isThoughtBasedExpressionsCache(directCache) ? directCache : null;
|
||||
}
|
||||
|
||||
function areThoughtBasedExpressionsCachesEqual(left, right) {
|
||||
return stableStringify(left) === stableStringify(right);
|
||||
}
|
||||
|
||||
function getThoughtBasedExpressionEntries(characterThoughtsData) {
|
||||
const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
|
||||
if (thoughtsConfig?.enabled === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!characterThoughtsData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const presentCharacters = parsePresentCharacters(characterThoughtsData);
|
||||
return presentCharacters
|
||||
.map(character => ({
|
||||
name: String(character?.name || '').trim(),
|
||||
thought: String(character?.ThoughtsContent || '').trim()
|
||||
}))
|
||||
.filter(character => character.name && character.thought && !OFF_SCENE_THOUGHT_PATTERN.test(character.thought));
|
||||
}
|
||||
|
||||
function buildRefreshSignature(thoughtEntries, expressionsSettingsSignature) {
|
||||
return JSON.stringify({
|
||||
expressionsSettingsSignature,
|
||||
thoughtEntries: thoughtEntries.map(entry => ({
|
||||
name: normalizeName(entry.name),
|
||||
thought: entry.thought,
|
||||
spriteFolderName: resolveSpriteFolderNameForCharacter(entry.name)
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshThoughtBasedExpressions({ force = false } = {}) {
|
||||
updateNativeExpressionDisplayVisibility();
|
||||
|
||||
if (!extensionSettings.enabled) {
|
||||
showNativeExpressionDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldUseThoughtBasedExpressions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isExpressionsExtensionEnabled()) {
|
||||
lastCompletedRefreshSignature = null;
|
||||
lastExpressionSettingsSignature = null;
|
||||
clearExpressionsCompatibilityCache();
|
||||
const portraitsChanged = applyThoughtBasedExpressionPortraits({});
|
||||
if (portraitsChanged) {
|
||||
saveChatData();
|
||||
}
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
return;
|
||||
}
|
||||
|
||||
const expressionsSettingsSignature = getExpressionsSettingsSignature();
|
||||
if (expressionsSettingsSignature !== lastExpressionSettingsSignature) {
|
||||
clearExpressionsCompatibilityCache();
|
||||
lastExpressionSettingsSignature = expressionsSettingsSignature;
|
||||
lastCompletedRefreshSignature = null;
|
||||
}
|
||||
|
||||
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback: true });
|
||||
const thoughtEntries = getThoughtBasedExpressionEntries(characterThoughtsData);
|
||||
const refreshSignature = buildRefreshSignature(thoughtEntries, expressionsSettingsSignature);
|
||||
if (!force && refreshSignature === lastCompletedRefreshSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceInfo = findThoughtSourceMessageInfo(characterThoughtsData);
|
||||
const cachedThoughtBasedExpressions = getSwipeThoughtBasedExpressionsCache(sourceInfo);
|
||||
const cachedEntries = cachedThoughtBasedExpressions?.entries && typeof cachedThoughtBasedExpressions.entries === 'object' && !Array.isArray(cachedThoughtBasedExpressions.entries)
|
||||
? cachedThoughtBasedExpressions.entries
|
||||
: {};
|
||||
const currentThoughtsSignature = normalizeThoughtPayload(characterThoughtsData);
|
||||
const classificationSettingsSignature = getExpressionClassificationSettingsSignature();
|
||||
const portraitSettingsSignature = getExpressionPortraitSettingsSignature();
|
||||
const runId = ++activeRefreshRunId;
|
||||
const nextPortraits = {};
|
||||
const nextCacheEntries = {};
|
||||
|
||||
for (const entry of thoughtEntries) {
|
||||
const portraitKey = normalizeName(entry.name);
|
||||
if (!portraitKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const spriteFolderName = resolveSpriteFolderNameForCharacter(entry.name);
|
||||
const cachedEntry = cachedEntries[portraitKey] && typeof cachedEntries[portraitKey] === 'object'
|
||||
? cachedEntries[portraitKey]
|
||||
: null;
|
||||
const previousSrc = nextPortraits[portraitKey] || thoughtBasedExpressionPortraits[portraitKey] || null;
|
||||
const canReuseExpression = cachedEntry
|
||||
&& cachedEntry.thought === entry.thought
|
||||
&& cachedEntry.classificationSettingsSignature === classificationSettingsSignature
|
||||
&& cachedEntry.spriteFolderName === spriteFolderName
|
||||
&& typeof cachedEntry.expression === 'string';
|
||||
|
||||
const expression = canReuseExpression
|
||||
? normalizeExpressionLabel(cachedEntry.expression)
|
||||
: normalizeExpressionLabel(await classifyExpressionText(entry.thought, { characterName: entry.name }));
|
||||
if (runId !== activeRefreshRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canReusePortrait = cachedEntry
|
||||
&& cachedEntry.thought === entry.thought
|
||||
&& cachedEntry.expression === expression
|
||||
&& cachedEntry.portraitSettingsSignature === portraitSettingsSignature
|
||||
&& cachedEntry.spriteFolderName === spriteFolderName
|
||||
&& cachedEntry.portraitResolved === true;
|
||||
|
||||
const portraitSrc = canReusePortrait
|
||||
? (isUsableThoughtBasedExpressionSrc(cachedEntry.portraitSrc) ? cachedEntry.portraitSrc : null)
|
||||
: await resolveExpressionPortraitForCharacter(entry.name, expression, { previousSrc });
|
||||
if (runId !== activeRefreshRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUsableThoughtBasedExpressionSrc(portraitSrc)) {
|
||||
nextPortraits[portraitKey] = portraitSrc;
|
||||
}
|
||||
|
||||
nextCacheEntries[portraitKey] = {
|
||||
name: entry.name,
|
||||
thought: entry.thought,
|
||||
spriteFolderName,
|
||||
classificationSettingsSignature,
|
||||
portraitSettingsSignature,
|
||||
expression,
|
||||
portraitSrc: isUsableThoughtBasedExpressionSrc(portraitSrc) ? portraitSrc : null,
|
||||
portraitResolved: true
|
||||
};
|
||||
}
|
||||
|
||||
if (runId !== activeRefreshRunId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cacheChanged = false;
|
||||
if (sourceInfo) {
|
||||
const nextCache = {
|
||||
version: THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION,
|
||||
thoughtsSignature: currentThoughtsSignature,
|
||||
entries: nextCacheEntries
|
||||
};
|
||||
|
||||
if (!areThoughtBasedExpressionsCachesEqual(cachedThoughtBasedExpressions, nextCache)) {
|
||||
setMessageSwipeTrackerField(sourceInfo.message, sourceInfo.swipeId, THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD, nextCache);
|
||||
cacheChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
lastCompletedRefreshSignature = refreshSignature;
|
||||
const portraitsChanged = applyThoughtBasedExpressionPortraits(nextPortraits);
|
||||
if (portraitsChanged || cacheChanged) {
|
||||
saveChatData();
|
||||
}
|
||||
if (portraitsChanged) {
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
}
|
||||
}
|
||||
|
||||
export function setThoughtBasedExpressionsRefreshHandler(handler) {
|
||||
thoughtBasedExpressionsRefreshHandler = typeof handler === 'function' ? handler : null;
|
||||
}
|
||||
|
||||
export function queueThoughtBasedExpressionsUpdate({ immediate = false, force = false } = {}) {
|
||||
clearScheduledRefresh();
|
||||
|
||||
const runRefresh = () => {
|
||||
refreshThoughtBasedExpressions({ force }).catch(error => {
|
||||
console.warn('[RPG Companion] Thought-based expressions update failed:', error);
|
||||
});
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
runRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
scheduledRefreshTimer = setTimeout(() => {
|
||||
scheduledRefreshTimer = null;
|
||||
runRefresh();
|
||||
}, REFRESH_DEBOUNCE_DELAY);
|
||||
}
|
||||
|
||||
export function initThoughtBasedExpressions() {
|
||||
const purged = purgeInvalidThoughtBasedExpressionPortraits();
|
||||
updateNativeExpressionDisplayVisibility();
|
||||
|
||||
if (purged) {
|
||||
saveChatData();
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
}
|
||||
|
||||
if (shouldUseThoughtBasedExpressions()) {
|
||||
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function onThoughtBasedExpressionsChatChanged() {
|
||||
if (!extensionSettings.enabled) {
|
||||
showNativeExpressionDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
clearScheduledRefresh();
|
||||
activeRefreshRunId += 1;
|
||||
lastCompletedRefreshSignature = null;
|
||||
lastExpressionSettingsSignature = null;
|
||||
clearExpressionsCompatibilityCache();
|
||||
|
||||
const purged = purgeInvalidThoughtBasedExpressionPortraits();
|
||||
if (purged) {
|
||||
saveChatData();
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
}
|
||||
|
||||
for (const delay of CHAT_CHANGE_RETRY_DELAYS) {
|
||||
setTimeout(() => {
|
||||
updateNativeExpressionDisplayVisibility();
|
||||
if (shouldUseThoughtBasedExpressions()) {
|
||||
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
|
||||
} else {
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
export function onThoughtBasedExpressionsSettingChanged(enabled) {
|
||||
updateNativeExpressionDisplayVisibility();
|
||||
|
||||
if (enabled) {
|
||||
const purged = purgeInvalidThoughtBasedExpressionPortraits();
|
||||
if (purged) {
|
||||
saveChatData();
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
}
|
||||
|
||||
if (shouldUseThoughtBasedExpressions()) {
|
||||
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
|
||||
} else {
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearScheduledRefresh();
|
||||
activeRefreshRunId += 1;
|
||||
lastCompletedRefreshSignature = null;
|
||||
lastExpressionSettingsSignature = null;
|
||||
clearExpressionsCompatibilityCache();
|
||||
notifyThoughtBasedExpressionsConsumers();
|
||||
}
|
||||
|
||||
export function onAlternatePresentCharactersVisibilityChanged() {
|
||||
updateNativeExpressionDisplayVisibility();
|
||||
|
||||
if (shouldUseThoughtBasedExpressions()) {
|
||||
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
clearScheduledRefresh();
|
||||
activeRefreshRunId += 1;
|
||||
lastCompletedRefreshSignature = null;
|
||||
lastExpressionSettingsSignature = null;
|
||||
}
|
||||
|
||||
export function onHideDefaultExpressionDisplaySettingChanged(enabled) {
|
||||
extensionSettings.hideDefaultExpressionDisplay = enabled === true;
|
||||
updateNativeExpressionDisplayVisibility();
|
||||
setTimeout(() => updateNativeExpressionDisplayVisibility(), 0);
|
||||
setTimeout(() => updateNativeExpressionDisplayVisibility(), 120);
|
||||
}
|
||||
|
||||
export function clearThoughtBasedExpressionsCache() {
|
||||
clearScheduledRefresh();
|
||||
activeRefreshRunId += 1;
|
||||
lastCompletedRefreshSignature = null;
|
||||
lastExpressionSettingsSignature = null;
|
||||
clearExpressionsCompatibilityCache();
|
||||
showNativeExpressionDisplay();
|
||||
}
|
||||
Reference in New Issue
Block a user