Replace speaker-based expression sync with thoughts-driven sync

This commit is contained in:
Tremendoussly
2026-03-15 21:38:45 +01:00
parent 9ef5b16663
commit c79c941871
11 changed files with 1003 additions and 419 deletions
+4 -4
View File
@@ -140,7 +140,7 @@ import {
} from './src/systems/ui/alternatePresentCharacters.js';
import {
initExpressionSync,
queueExpressionCaptureForSpeaker,
queueExpressionSyncFromThoughts,
onExpressionSyncSettingChanged,
onAlternatePresentCharactersVisibilityChanged,
onHideDefaultExpressionDisplaySettingChanged,
@@ -1425,7 +1425,7 @@ jQuery(async () => {
const renderedMessage = chat[messageId];
if (renderedMessage && !renderedMessage.is_user && !renderedMessage.is_system) {
queueExpressionCaptureForSpeaker(renderedMessage.name);
queueExpressionSyncFromThoughts();
}
});
@@ -1436,7 +1436,7 @@ jQuery(async () => {
const updatedMessage = chat[messageId];
if (updatedMessage && !updatedMessage.is_user && !updatedMessage.is_system) {
queueExpressionCaptureForSpeaker(updatedMessage.name);
queueExpressionSyncFromThoughts();
}
});
@@ -1447,7 +1447,7 @@ jQuery(async () => {
const swipedMessage = chat[messageIndex];
if (swipedMessage && !swipedMessage.is_user && !swipedMessage.is_system) {
queueExpressionCaptureForSpeaker(swipedMessage.name);
queueExpressionSyncFromThoughts({ immediate: true });
}
});
+2 -2
View File
@@ -364,8 +364,8 @@ export function clearSessionAvatarPrompts() {
}
/**
* Per-chat storage for synced Character Expressions portraits.
* Maps normalized character names to the last captured expression image URL.
* Per-chat storage for thoughts-synced Character Expressions portraits.
* Maps normalized character names to the current below-chat portrait URL.
*/
export let syncedExpressionPortraits = {};
+1 -1
View File
@@ -37,7 +37,7 @@
"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.syncBelowChatPresentCharactersExpressionsNote": "Use SillyTavern Character Expressions to classify each present character's thoughts for the below-chat panel. If Character Expressions uses Main API with Full Context, this may increase token usage.",
"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",
+1 -1
View File
@@ -38,7 +38,7 @@
"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": "Synchroniser les expressions dans le panneau sous le chat",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Utiliser le portrait d'expression SillyTavern actuel de chaque personnage dans le panneau des personnages présents sous le chat.",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Utiliser Character Expressions de SillyTavern pour classifier les pensées de chaque personnage présent dans le panneau sous le chat. Si Character Expressions utilise Main API avec Full Context, cela peut augmenter l'utilisation de tokens.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Masquer l'affichage d'expressions par défaut",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Masquer l'affichage intégré des expressions de personnage de SillyTavern.",
"template.settingsModal.display.narratorMode": "Mode Narrateur",
+1 -1
View File
@@ -37,7 +37,7 @@
"template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressions": "Синхронизировать выражения в панели под чатом",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Использовать текущий портрет выражения SillyTavern каждого персонажа в панели «Присутствующие персонажи» под чатом.",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "Использовать Character Expressions в SillyTavern для классификации мыслей каждого присутствующего персонажа в панели под чатом. Если Character Expressions использует Main API с Full Context, расход token-ов может вырасти.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Скрыть отображение выражений по умолчанию",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Скрыть встроенное отображение выражений персонажей SillyTavern.",
"template.settingsModal.display.narratorMode": "Режим расказчика",
+1 -1
View File
@@ -33,7 +33,7 @@
"template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressions": "在聊天下方面板同步表情",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "在聊天下方的在場角色面板中,使用每個角色目前的 SillyTavern 表情頭像。",
"template.settingsModal.display.syncBelowChatPresentCharactersExpressionsNote": "使用 SillyTavern Character Expressions 對聊天下方面板中每個在場角色的想法進行分類。如果 Character Expressions 使用 Main API + Full Context,可能會增加 token 使用量。",
"template.settingsModal.display.hideDefaultExpressionDisplay": "隱藏預設表情顯示",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "隱藏 SillyTavern 內建的角色表情顯示。",
"template.settingsModal.display.showInventory": "顯示物品欄",
+353 -404
View File
@@ -1,86 +1,55 @@
/**
* 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.
* Derives expression portraits from the current Present Characters thoughts
* payload, while keeping SillyTavern's native Character Expressions widget
* independent from the below-chat panel.
*/
import { chat } from '../../../../../../../script.js';
import { getContext } from '../../../../../../extensions.js';
import {
extensionSettings,
syncedExpressionPortraits,
setSyncedExpressionPortrait,
removeSyncedExpressionPortrait
setSyncedExpressionPortraits
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { normalizeImageSrc } from '../../utils/imageUrls.js';
import {
getCurrentMessageSwipeTrackerData,
saveChatData,
setMessageSwipeTrackerField
} from '../../core/persistence.js';
import { isUsableExpressionSrc } from '../../utils/expressionPortraits.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 SYNC_DEBOUNCE_DELAY = 80;
const EXPRESSION_SYNC_CACHE_VERSION = 1;
const EXPRESSION_SYNC_CACHE_FIELD = 'expressionSync';
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;
let refreshExpressionConsumersHandler = null;
let scheduledSyncTimer = null;
let activeSyncRunId = 0;
let lastCompletedSyncSignature = null;
let lastExpressionsSettingsSignature = null;
function normalizeName(name) {
return String(name || '').trim().toLowerCase();
}
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 + ' ');
}
function normalizeExpressionSrc(src) {
return normalizeImageSrc(src);
}
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;
}
export function setExpressionSyncRefreshHandler(handler) {
refreshExpressionConsumersHandler = typeof handler === 'function' ? handler : 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;
}
@@ -91,185 +60,6 @@ function shouldSyncExpressionPortraits() {
&& extensionSettings.showAlternatePresentCharactersPanel === true;
}
function shouldRunExpressionObservers() {
return shouldSyncExpressionPortraits() || shouldHideNativeExpressionDisplay();
}
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() {
refreshExpressionConsumersHandler?.();
}
@@ -327,199 +117,345 @@ function syncNativeExpressionDisplayVisibility() {
}
}
function teardownExpressionObservers() {
if (expressionContainerObserver) {
expressionContainerObserver.disconnect();
expressionContainerObserver = null;
function clearScheduledSync() {
if (scheduledSyncTimer !== null) {
clearTimeout(scheduledSyncTimer);
scheduledSyncTimer = null;
}
if (expressionImageObserver) {
expressionImageObserver.disconnect();
expressionImageObserver = null;
}
observedExpressionImage = null;
}
function resetPendingExpressionCaptureState() {
clearScheduledCaptures();
pendingCaptureRequestId += 1;
pendingSpeakerName = null;
pendingSpeakerBaselineSignature = null;
pendingSpeakerQueuedAt = 0;
lastCapturedExpressionSrc = 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 captureExpressionForSpeaker(speakerName, expectedRequestId = null) {
if (!shouldSyncExpressionPortraits()) {
return false;
function normalizeThoughtPayload(payload) {
if (!payload) {
return null;
}
if (expectedRequestId !== null && expectedRequestId !== pendingCaptureRequestId) {
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;
}
const name = normalizeName(speakerName || pendingSpeakerName || getLatestAssistantSpeakerName());
if (!name) {
return leftKeys.every(key => left[key] === right[key]);
}
function applySyncedExpressionPortraits(nextPortraits) {
if (arePortraitMapsEqual(syncedExpressionPortraits, nextPortraits)) {
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();
setSyncedExpressionPortraits(nextPortraits);
return true;
}
function observeExpressionImage(img) {
if (!shouldRunExpressionObservers()) {
return;
}
if (!img || observedExpressionImage === img) {
return;
function purgeInvalidSyncedExpressionPortraits() {
const nextPortraits = {};
for (const [characterName, src] of Object.entries(syncedExpressionPortraits)) {
if (isUsableExpressionSrc(src)) {
nextPortraits[characterName] = src;
}
}
if (expressionImageObserver) {
expressionImageObserver.disconnect();
return applySyncedExpressionPortraits(nextPortraits);
}
function getMessageThoughtPayload(message) {
if (!message || message.is_user) {
return null;
}
observedExpressionImage = img;
expressionImageObserver = new MutationObserver(() => {
captureExpressionForSpeaker(pendingSpeakerName, pendingCaptureRequestId);
});
const swipeData = getCurrentMessageSwipeTrackerData(message);
return normalizeThoughtPayload(swipeData?.characterThoughts ?? null);
}
expressionImageObserver.observe(img, {
attributes: true,
attributeFilter: ['src', 'class', 'style', 'title', 'data-expression', 'data-sprite-folder-name', 'data-sprite-filename']
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 getSwipeExpressionSyncCache(sourceInfo) {
const cache = sourceInfo?.swipeData?.[EXPRESSION_SYNC_CACHE_FIELD];
if (!cache || typeof cache !== 'object' || Array.isArray(cache)) {
return null;
}
if (cache.version !== EXPRESSION_SYNC_CACHE_VERSION) {
return null;
}
return cache;
}
function areExpressionSyncCachesEqual(left, right) {
return stableStringify(left) === stableStringify(right);
}
function getThoughtSyncEntries(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 buildSyncSignature(thoughtEntries, expressionsSettingsSignature) {
return JSON.stringify({
expressionsSettingsSignature,
thoughtEntries: thoughtEntries.map(entry => ({
name: normalizeName(entry.name),
thought: entry.thought,
spriteFolderName: resolveSpriteFolderNameForCharacter(entry.name)
}))
});
}
function ensureExpressionObservers() {
async function syncExpressionsFromThoughts({ force = false } = {}) {
syncNativeExpressionDisplayVisibility();
if (!shouldRunExpressionObservers()) {
teardownExpressionObservers();
resetPendingExpressionCaptureState();
return false;
}
const currentImg = findExpressionImageElement(pendingSpeakerName);
if (currentImg) {
observeExpressionImage(currentImg);
} else if (expressionImageObserver) {
expressionImageObserver.disconnect();
expressionImageObserver = null;
observedExpressionImage = null;
}
if (expressionContainerObserver) {
if (!extensionSettings.enabled) {
showNativeExpressionDisplay();
return;
}
expressionContainerObserver = new MutationObserver(() => {
if (!shouldRunExpressionObservers()) {
teardownExpressionObservers();
syncNativeExpressionDisplayVisibility();
if (!shouldSyncExpressionPortraits()) {
return;
}
if (!isExpressionsExtensionEnabled()) {
lastCompletedSyncSignature = null;
lastExpressionsSettingsSignature = null;
clearExpressionsCompatibilityCache();
const portraitsChanged = applySyncedExpressionPortraits({});
if (portraitsChanged) {
saveChatData();
}
refreshExpressionConsumers();
return;
}
const expressionsSettingsSignature = getExpressionsSettingsSignature();
if (expressionsSettingsSignature !== lastExpressionsSettingsSignature) {
clearExpressionsCompatibilityCache();
lastExpressionsSettingsSignature = expressionsSettingsSignature;
lastCompletedSyncSignature = null;
}
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback: true });
const thoughtEntries = getThoughtSyncEntries(characterThoughtsData);
const syncSignature = buildSyncSignature(thoughtEntries, expressionsSettingsSignature);
if (!force && syncSignature === lastCompletedSyncSignature) {
return;
}
const sourceInfo = findThoughtSourceMessageInfo(characterThoughtsData);
const cachedSyncData = getSwipeExpressionSyncCache(sourceInfo);
const cachedEntries = cachedSyncData?.entries && typeof cachedSyncData.entries === 'object' && !Array.isArray(cachedSyncData.entries)
? cachedSyncData.entries
: {};
const currentThoughtsSignature = normalizeThoughtPayload(characterThoughtsData);
const classificationSettingsSignature = getExpressionClassificationSettingsSignature();
const portraitSettingsSignature = getExpressionPortraitSettingsSignature();
const runId = ++activeSyncRunId;
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] || syncedExpressionPortraits[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 !== activeSyncRunId) {
return;
}
const img = findExpressionImageElement(pendingSpeakerName);
if (img) {
observeExpressionImage(img);
captureExpressionForSpeaker(pendingSpeakerName, pendingCaptureRequestId);
} else if (expressionImageObserver) {
expressionImageObserver.disconnect();
expressionImageObserver = null;
observedExpressionImage = null;
const canReusePortrait = cachedEntry
&& cachedEntry.thought === entry.thought
&& cachedEntry.expression === expression
&& cachedEntry.portraitSettingsSignature === portraitSettingsSignature
&& cachedEntry.spriteFolderName === spriteFolderName
&& cachedEntry.portraitResolved === true;
const portraitSrc = canReusePortrait
? (isUsableExpressionSrc(cachedEntry.portraitSrc) ? cachedEntry.portraitSrc : null)
: await resolveExpressionPortraitForCharacter(entry.name, expression, { previousSrc });
if (runId !== activeSyncRunId) {
return;
}
syncNativeExpressionDisplayVisibility();
});
if (isUsableExpressionSrc(portraitSrc)) {
nextPortraits[portraitKey] = portraitSrc;
}
expressionContainerObserver.observe(document.body, {
childList: true,
subtree: true
});
return true;
}
function clearScheduledCaptures() {
for (const timer of scheduledCaptureTimers) {
clearTimeout(timer);
nextCacheEntries[portraitKey] = {
name: entry.name,
thought: entry.thought,
spriteFolderName,
classificationSettingsSignature,
portraitSettingsSignature,
expression,
portraitSrc: isUsableExpressionSrc(portraitSrc) ? portraitSrc : null,
portraitResolved: true
};
}
scheduledCaptureTimers = [];
}
export function queueExpressionCaptureForSpeaker(speakerName) {
if (!shouldSyncExpressionPortraits()) {
if (runId !== activeSyncRunId) {
return;
}
pendingSpeakerName = normalizeName(speakerName || getLatestAssistantSpeakerName());
if (!pendingSpeakerName) {
return;
let cacheChanged = false;
if (sourceInfo) {
const nextCache = {
version: EXPRESSION_SYNC_CACHE_VERSION,
thoughtsSignature: currentThoughtsSignature,
entries: nextCacheEntries
};
if (!areExpressionSyncCachesEqual(cachedSyncData, nextCache)) {
setMessageSwipeTrackerField(sourceInfo.message, sourceInfo.swipeId, EXPRESSION_SYNC_CACHE_FIELD, nextCache);
cacheChanged = true;
}
}
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);
lastCompletedSyncSignature = syncSignature;
const portraitsChanged = applySyncedExpressionPortraits(nextPortraits);
if (portraitsChanged || cacheChanged) {
saveChatData();
}
if (portraitsChanged) {
refreshExpressionConsumers();
}
}
export function syncExpressionFromLatestMessage() {
if (!shouldSyncExpressionPortraits()) {
export function setExpressionSyncRefreshHandler(handler) {
refreshExpressionConsumersHandler = typeof handler === 'function' ? handler : null;
}
export function queueExpressionSyncFromThoughts({ immediate = false, force = false } = {}) {
clearScheduledSync();
const runSync = () => {
syncExpressionsFromThoughts({ force }).catch(error => {
console.warn('[RPG Companion] Thoughts-driven expression sync failed:', error);
});
};
if (immediate) {
runSync();
return;
}
queueExpressionCaptureForSpeaker(getLatestAssistantSpeakerName());
scheduledSyncTimer = setTimeout(() => {
scheduledSyncTimer = null;
runSync();
}, SYNC_DEBOUNCE_DELAY);
}
export function initExpressionSync() {
if (purgeInvalidSyncedExpressionPortraits()) {
const purged = purgeInvalidSyncedExpressionPortraits();
syncNativeExpressionDisplayVisibility();
if (purged) {
saveChatData();
refreshExpressionConsumers();
}
ensureExpressionObservers();
if (shouldSyncExpressionPortraits()) {
syncExpressionFromLatestMessage();
queueExpressionSyncFromThoughts({ immediate: true, force: true });
}
}
@@ -529,18 +465,23 @@ export function onExpressionSyncChatChanged() {
return;
}
clearScheduledSync();
activeSyncRunId += 1;
lastCompletedSyncSignature = null;
lastExpressionsSettingsSignature = null;
clearExpressionsCompatibilityCache();
const purged = purgeInvalidSyncedExpressionPortraits();
if (purged) {
saveChatData();
refreshExpressionConsumers();
}
const retryDelays = [0, 80, 220, 500];
for (const delay of retryDelays) {
for (const delay of CHAT_CHANGE_RETRY_DELAYS) {
setTimeout(() => {
ensureExpressionObservers();
syncNativeExpressionDisplayVisibility();
if (shouldSyncExpressionPortraits()) {
syncExpressionFromLatestMessage();
queueExpressionSyncFromThoughts({ immediate: true, force: true });
} else {
refreshExpressionConsumers();
}
@@ -549,49 +490,57 @@ export function onExpressionSyncChatChanged() {
}
export function onExpressionSyncSettingChanged(enabled) {
syncNativeExpressionDisplayVisibility();
if (enabled) {
const purged = purgeInvalidSyncedExpressionPortraits();
initExpressionSync();
if (!purged) {
if (purged) {
saveChatData();
refreshExpressionConsumers();
}
if (shouldSyncExpressionPortraits()) {
syncExpressionFromLatestMessage();
queueExpressionSyncFromThoughts({ immediate: true, force: true });
} else {
refreshExpressionConsumers();
}
return;
}
const observersActive = ensureExpressionObservers();
if (observersActive) {
resetPendingExpressionCaptureState();
}
clearScheduledSync();
activeSyncRunId += 1;
lastCompletedSyncSignature = null;
lastExpressionsSettingsSignature = null;
clearExpressionsCompatibilityCache();
refreshExpressionConsumers();
}
export function onAlternatePresentCharactersVisibilityChanged() {
const shouldSyncPortraits = shouldSyncExpressionPortraits();
const observersActive = ensureExpressionObservers();
syncNativeExpressionDisplayVisibility();
if (shouldSyncPortraits) {
syncExpressionFromLatestMessage();
if (shouldSyncExpressionPortraits()) {
queueExpressionSyncFromThoughts({ immediate: true, force: true });
return;
}
if (observersActive) {
resetPendingExpressionCaptureState();
}
clearScheduledSync();
activeSyncRunId += 1;
lastCompletedSyncSignature = null;
lastExpressionsSettingsSignature = null;
}
export function onHideDefaultExpressionDisplaySettingChanged(enabled) {
extensionSettings.hideDefaultExpressionDisplay = enabled === true;
ensureExpressionObservers();
syncNativeExpressionDisplayVisibility();
setTimeout(() => syncNativeExpressionDisplayVisibility(), 0);
setTimeout(() => syncNativeExpressionDisplayVisibility(), 120);
}
export function clearExpressionSyncCache() {
resetPendingExpressionCaptureState();
teardownExpressionObservers();
clearScheduledSync();
activeSyncRunId += 1;
lastCompletedSyncSignature = null;
lastExpressionsSettingsSignature = null;
clearExpressionsCompatibilityCache();
showNativeExpressionDisplay();
}
+6
View File
@@ -22,6 +22,7 @@ import {
} from '../../utils/presentCharacters.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
import { queueExpressionSyncFromThoughts } from '../integration/expressionSync.js';
/**
* Helper to generate lock icon HTML if setting is enabled
@@ -92,6 +93,7 @@ function getStatColor(percentage, lowColor, highColor, lowOpacity = 100, highOpa
*/
export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
renderAlternatePresentCharacters({ useCommittedFallback });
queueExpressionSyncFromThoughts();
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return;
@@ -1281,6 +1283,10 @@ export function updateCharacterField(characterName, field, value) {
// console.log('[RPG Companion] Is editing thoughts?', isEditingThoughts, 'Field:', field, 'Thoughts field name:', thoughtsFieldName);
// console.log('[RPG Companion] After update - lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts);
if (field === 'name' || isEditingThoughts) {
queueExpressionSyncFromThoughts({ immediate: true, force: true });
}
if (isEditingThoughts && extensionSettings.showThoughtsInChat) {
// console.log('[RPG Companion] Updating chat thought bubbles');
// Update chat thought bubbles when thoughts are edited
+5 -5
View File
@@ -7,6 +7,7 @@ import {
normalizeImageSrc,
resolveImageUrl
} from './imageUrls.js';
import { isExpressionsExtensionEnabled } from './sillyTavernExpressions.js';
function normalizeName(name) {
return String(name || '').trim().toLowerCase();
@@ -40,11 +41,6 @@ export function isUsableExpressionSrc(src) {
return false;
}
const lower = normalized.toLowerCase();
if (lower.includes('/img/default-expressions/') || lower.includes('/default-expressions/')) {
return false;
}
if (isDocumentLikeUrl(normalized)) {
return false;
}
@@ -53,6 +49,10 @@ export function isUsableExpressionSrc(src) {
}
export function getExpressionPortraitForCharacter(characterName) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const target = normalizeName(characterName);
if (!target) {
return null;
+3
View File
@@ -135,6 +135,7 @@ export function parsePresentCharacters(characterThoughtsData, { enabledFields =
const lines = characterThoughtsData.split('\n');
let currentCharacter = null;
const thoughtsLabel = extensionSettings.trackerConfig?.presentCharacters?.thoughts?.name || 'Thoughts';
for (const line of lines) {
if (!line.trim()
@@ -178,6 +179,8 @@ export function parsePresentCharacters(characterThoughtsData, { enabledFields =
currentCharacter[statMatch[1].trim()] = parseInt(statMatch[2], 10);
}
}
} else if (line.trim().startsWith(thoughtsLabel + ':') && currentCharacter) {
currentCharacter.ThoughtsContent = line.substring(line.indexOf(':') + 1).trim();
}
}
+626
View File
@@ -0,0 +1,626 @@
import { Fuse } from '../../../../../../lib.js';
import {
characters,
eventSource,
event_types,
generateQuietPrompt,
generateRaw,
getRequestHeaders,
online_status,
substituteParams,
substituteParamsExtended,
this_chid
} from '../../../../../../script.js';
import {
doExtrasFetch,
extension_settings as stExtensionSettings,
getApiUrl,
modules
} from '../../../../../extensions.js';
import { selected_group, getGroupMembers } from '../../../../../group-chats.js';
import { removeReasoningFromString } from '../../../../../reasoning.js';
import { isJsonSchemaSupported } from '../../../../../textgen-settings.js';
import { trimToEndSentence, trimToStartSentence, waitUntilCondition } from '../../../../../utils.js';
import { generateWebLlmChatPrompt, isWebLlmSupported } from '../../../../../extensions/shared.js';
import { namesMatch } from './presentCharacters.js';
import { normalizeImageSrc } from './imageUrls.js';
const EXPRESSIONS_EXTENSION_NAME = 'expressions';
const DEFAULT_FALLBACK_EXPRESSION = 'joy';
const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';
const DEFAULT_EXPRESSIONS = [
'admiration',
'amusement',
'anger',
'annoyance',
'approval',
'caring',
'confusion',
'curiosity',
'desire',
'disappointment',
'disapproval',
'disgust',
'embarrassment',
'excitement',
'fear',
'gratitude',
'grief',
'joy',
'love',
'nervousness',
'optimism',
'pride',
'realization',
'relief',
'remorse',
'sadness',
'surprise',
'neutral'
];
export const EXPRESSION_API = {
local: 0,
extras: 1,
llm: 2,
webllm: 3,
none: 99
};
const PROMPT_TYPE = {
raw: 'raw',
full: 'full'
};
let expressionsListCache = null;
const spriteCache = new Map();
function getNormalizedExpressionsSettings() {
const settings = stExtensionSettings.expressions || {};
return {
api: Number.isInteger(settings.api) ? settings.api : EXPRESSION_API.none,
custom: Array.isArray(settings.custom) ? settings.custom.slice() : [],
showDefault: settings.showDefault === true,
translate: settings.translate === true,
fallbackExpression: typeof settings.fallback_expression === 'string' && settings.fallback_expression.trim()
? settings.fallback_expression.trim().toLowerCase()
: '',
llmPrompt: typeof settings.llmPrompt === 'string' && settings.llmPrompt.trim()
? settings.llmPrompt
: DEFAULT_LLM_PROMPT,
allowMultiple: settings.allowMultiple !== false,
rerollIfSame: settings.rerollIfSame === true,
filterAvailable: settings.filterAvailable === true,
promptType: settings.promptType === PROMPT_TYPE.full ? PROMPT_TYPE.full : PROMPT_TYPE.raw,
expressionOverrides: Array.isArray(stExtensionSettings.expressionOverrides)
? stExtensionSettings.expressionOverrides.slice()
: []
};
}
export function isExpressionsExtensionEnabled() {
return !stExtensionSettings.disabledExtensions?.includes(EXPRESSIONS_EXTENSION_NAME);
}
export function getExpressionsSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
api: settings.api,
custom: settings.custom,
showDefault: settings.showDefault,
translate: settings.translate,
fallbackExpression: settings.fallbackExpression,
llmPrompt: settings.llmPrompt,
allowMultiple: settings.allowMultiple,
rerollIfSame: settings.rerollIfSame,
filterAvailable: settings.filterAvailable,
promptType: settings.promptType,
expressionOverrides: settings.expressionOverrides
});
}
export function getExpressionClassificationSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
api: settings.api,
custom: settings.custom,
translate: settings.translate,
fallbackExpression: settings.fallbackExpression,
llmPrompt: settings.llmPrompt,
filterAvailable: settings.filterAvailable,
promptType: settings.promptType
});
}
export function getExpressionPortraitSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
custom: settings.custom,
showDefault: settings.showDefault,
fallbackExpression: settings.fallbackExpression,
allowMultiple: settings.allowMultiple,
rerollIfSame: settings.rerollIfSame
});
}
export function clearExpressionsCompatibilityCache() {
expressionsListCache = null;
spriteCache.clear();
}
function uniqueValues(values) {
return values.filter((value, index) => values.indexOf(value) === index);
}
function normalizeExpressionLabel(label) {
return String(label || '').trim().toLowerCase();
}
function stripExtension(fileName) {
return String(fileName || '').replace(/\.[^/.]+$/, '');
}
function resolveFolderOverride(folderName, expressionOverrides) {
const override = expressionOverrides.find(entry => entry?.name === folderName);
return override?.path ? String(override.path) : folderName;
}
function getAvatarFolderName(avatar) {
if (!avatar || avatar === 'none') {
return '';
}
return String(avatar).replace(/\.[^/.]+$/, '');
}
export function resolveSpriteFolderNameForCharacter(characterName) {
if (!characterName) {
return '';
}
const settings = getNormalizedExpressionsSettings();
const groupId = selected_group;
if (groupId) {
try {
const groupMembers = getGroupMembers(groupId) || [];
const matchingMember = groupMembers.find(member =>
member?.name && namesMatch(member.name, characterName));
const memberFolder = getAvatarFolderName(matchingMember?.avatar);
if (memberFolder) {
return resolveFolderOverride(memberFolder, settings.expressionOverrides);
}
} catch {
// Ignore group lookup issues and continue through the fallback chain.
}
}
if (Array.isArray(characters) && characters.length > 0) {
const matchingCharacter = characters.find(character =>
character?.name && namesMatch(character.name, characterName));
const characterFolder = getAvatarFolderName(matchingCharacter?.avatar);
if (characterFolder) {
return resolveFolderOverride(characterFolder, settings.expressionOverrides);
}
}
if (this_chid !== undefined && characters?.[this_chid]?.name && namesMatch(characters[this_chid].name, characterName)) {
const currentCharacterFolder = getAvatarFolderName(characters[this_chid].avatar);
if (currentCharacterFolder) {
return resolveFolderOverride(currentCharacterFolder, settings.expressionOverrides);
}
}
return '';
}
function sampleClassifyText(text, expressionsApi) {
if (!text) {
return '';
}
let result = substituteParams(text).replace(/[*"]/g, '');
if (expressionsApi === EXPRESSION_API.llm) {
return result.trim();
}
const SAMPLE_THRESHOLD = 500;
const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2;
if (text.length < SAMPLE_THRESHOLD) {
result = trimToEndSentence(result);
} else {
result = `${trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD))} ${trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD))}`;
}
return result.trim();
}
function getJsonSchema(labels) {
return {
$schema: 'http://json-schema.org/draft-04/schema#',
type: 'object',
properties: {
emotion: {
type: 'string',
enum: labels
}
},
required: ['emotion'],
additionalProperties: false
};
}
function buildFullContextThoughtPrompt(prompt, text) {
return [
prompt,
'',
'Classify the emotion of the following text instead of the last chat message.',
'Output exactly one label from the allowed list.',
'',
`Text: ${text}`
].join('\n');
}
function parseLlmResponse(emotionResponse, labels) {
try {
const parsedEmotion = JSON.parse(emotionResponse);
const response = parsedEmotion?.emotion?.trim()?.toLowerCase();
if (response && labels.includes(response)) {
return response;
}
} catch {
// Fall through to the fuzzy parse below.
}
const cleanedResponse = removeReasoningFromString(String(emotionResponse || ''));
const lowerCaseResponse = cleanedResponse.toLowerCase();
for (const label of labels) {
if (lowerCaseResponse.includes(label.toLowerCase())) {
return label;
}
}
const fuse = new Fuse(labels, { includeScore: true });
const match = fuse.search(cleanedResponse)[0];
if (match?.item) {
return match.item;
}
throw new Error('Could not parse expression label from response');
}
async function resolveExpressionsList() {
const settings = getNormalizedExpressionsSettings();
try {
if (settings.api === EXPRESSION_API.extras && modules.includes('classify')) {
const url = new URL(getApiUrl());
url.pathname = '/api/classify/labels';
const response = await doExtrasFetch(url, {
method: 'GET',
headers: { 'Bypass-Tunnel-Reminder': 'bypass' }
});
if (response.ok) {
const data = await response.json();
return Array.isArray(data?.labels)
? data.labels.map(normalizeExpressionLabel).filter(Boolean)
: DEFAULT_EXPRESSIONS.slice();
}
}
if (settings.api === EXPRESSION_API.local) {
const response = await fetch('/api/extra/classify/labels', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true })
});
if (response.ok) {
const data = await response.json();
return Array.isArray(data?.labels)
? data.labels.map(normalizeExpressionLabel).filter(Boolean)
: DEFAULT_EXPRESSIONS.slice();
}
}
} catch {
// Fall back to the built-in labels below.
}
return DEFAULT_EXPRESSIONS.slice();
}
async function getAvailableExpressionLabelsForCharacter(characterName) {
const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName);
if (!spriteFolderName) {
return [];
}
const expressions = await getSpritesList(spriteFolderName);
return expressions
.filter(expression => Array.isArray(expression?.files) && expression.files.length > 0)
.map(expression => String(expression.label || '').trim().toLowerCase())
.filter(Boolean);
}
export async function getExpressionsList({ characterName = '', filterAvailable = false } = {}) {
if (!Array.isArray(expressionsListCache)) {
expressionsListCache = await resolveExpressionsList();
}
const settings = getNormalizedExpressionsSettings();
const expressions = uniqueValues([...expressionsListCache, ...settings.custom.map(value => String(value).trim().toLowerCase())])
.filter(Boolean);
if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(settings.api)) {
return expressions;
}
const availableExpressions = await getAvailableExpressionLabelsForCharacter(characterName);
if (!availableExpressions.length) {
return expressions;
}
return expressions.filter(expression => availableExpressions.includes(expression));
}
async function getSpritesList(spriteFolderName) {
if (!spriteFolderName) {
return [];
}
if (spriteCache.has(spriteFolderName)) {
return spriteCache.get(spriteFolderName);
}
try {
const response = await fetch(`/api/sprites/get?name=${encodeURIComponent(spriteFolderName)}`);
const sprites = response.ok ? await response.json() : [];
const grouped = [];
for (const sprite of Array.isArray(sprites) ? sprites : []) {
const fileName = String(sprite?.path || '').split('/').pop()?.split('?')[0] || '';
const imageData = {
expression: normalizeExpressionLabel(sprite?.label),
fileName,
title: stripExtension(fileName),
imageSrc: String(sprite?.path || ''),
type: 'success',
isCustom: getNormalizedExpressionsSettings().custom.includes(normalizeExpressionLabel(sprite?.label))
};
let existing = grouped.find(entry => entry.label === imageData.expression);
if (!existing) {
existing = { label: imageData.expression, files: [] };
grouped.push(existing);
}
existing.files.push(imageData);
}
for (const expression of grouped) {
expression.files.sort((left, right) => {
if (left.title === expression.label) return -1;
if (right.title === expression.label) return 1;
return left.title.localeCompare(right.title);
});
}
spriteCache.set(spriteFolderName, grouped);
return grouped;
} catch {
spriteCache.set(spriteFolderName, []);
return [];
}
}
function chooseSpriteForExpression(expressions, expression, { previousSrc = null } = {}) {
const settings = getNormalizedExpressionsSettings();
let sprite = expressions.find(entry => entry.label === expression);
if (!(sprite?.files?.length > 0) && settings.fallbackExpression) {
sprite = expressions.find(entry => entry.label === settings.fallbackExpression);
}
if (!(sprite?.files?.length > 0)) {
return null;
}
let candidates = sprite.files;
if (settings.allowMultiple && sprite.files.length > 1) {
if (settings.rerollIfSame) {
const filtered = sprite.files.filter(file => !previousSrc || file.imageSrc !== previousSrc);
if (filtered.length > 0) {
candidates = filtered;
}
}
return candidates[Math.floor(Math.random() * candidates.length)] || null;
}
return candidates[0] || null;
}
function getDefaultExpressionImage(expression, customExpressions) {
let normalizedExpression = String(expression || '').trim().toLowerCase();
if (!normalizedExpression) {
return '';
}
if (customExpressions.includes(normalizedExpression)) {
normalizedExpression = DEFAULT_FALLBACK_EXPRESSION;
}
return `/img/default-expressions/${normalizedExpression}.png`;
}
export async function classifyExpressionText(text, { characterName = '' } = {}) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const settings = getNormalizedExpressionsSettings();
if (!text) {
return settings.fallbackExpression || '';
}
if (settings.api === EXPRESSION_API.none) {
return settings.fallbackExpression || '';
}
let processedText = text;
if (settings.translate && typeof globalThis.translate === 'function') {
processedText = await globalThis.translate(processedText, 'en');
}
processedText = sampleClassifyText(processedText, settings.api);
if (!processedText) {
return settings.fallbackExpression || '';
}
const labels = await getExpressionsList({
characterName,
filterAvailable: settings.filterAvailable === true
});
const fallbackLabels = labels.length > 0 ? labels : await getExpressionsList();
try {
switch (settings.api) {
case EXPRESSION_API.local: {
const response = await fetch('/api/extra/classify', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ text: processedText })
});
if (response.ok) {
const data = await response.json();
return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase();
}
break;
}
case EXPRESSION_API.extras: {
if (!modules.includes('classify')) {
return settings.fallbackExpression || '';
}
const url = new URL(getApiUrl());
url.pathname = '/api/classify';
const response = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass'
},
body: JSON.stringify({ text: processedText })
});
if (response.ok) {
const data = await response.json();
return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase();
}
break;
}
case EXPRESSION_API.llm: {
await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
const labelsString = fallbackLabels.map(label => `"${label}"`).join(', ');
const basePrompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString });
const prompt = settings.promptType === PROMPT_TYPE.full
? buildFullContextThoughtPrompt(basePrompt, processedText)
: basePrompt;
const onReady = (args) => {
if (isJsonSchemaSupported()) {
Object.assign(args, {
top_k: 1,
stop: [],
stopping_strings: [],
custom_token_bans: [],
json_schema: getJsonSchema(fallbackLabels)
});
}
};
eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onReady);
const responseText = settings.promptType === PROMPT_TYPE.full
? await generateQuietPrompt({ quietPrompt: prompt })
: await generateRaw({ prompt: processedText, systemPrompt: prompt });
return parseLlmResponse(responseText, fallbackLabels);
}
case EXPRESSION_API.webllm: {
if (!isWebLlmSupported()) {
return settings.fallbackExpression || '';
}
const labelsString = fallbackLabels.map(label => `"${label}"`).join(', ');
const prompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString });
const responseText = await generateWebLlmChatPrompt([
{
role: 'user',
content: `${processedText}\n\n${prompt}`
}
]);
return parseLlmResponse(responseText, fallbackLabels);
}
default:
break;
}
} catch {
return settings.fallbackExpression || '';
}
return settings.fallbackExpression || '';
}
export async function resolveExpressionPortraitForCharacter(characterName, expression, { previousSrc = null } = {}) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const settings = getNormalizedExpressionsSettings();
const normalizedExpression = String(expression || '').trim().toLowerCase();
const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName);
if (spriteFolderName) {
const expressions = await getSpritesList(spriteFolderName);
const spriteFile = chooseSpriteForExpression(expressions, normalizedExpression, { previousSrc });
const spriteSrc = normalizeImageSrc(spriteFile?.imageSrc || '');
if (spriteSrc) {
return spriteSrc;
}
}
if (settings.showDefault) {
const defaultExpression = normalizedExpression || settings.fallbackExpression;
const defaultImage = normalizeImageSrc(getDefaultExpressionImage(defaultExpression, settings.custom));
if (defaultImage) {
return defaultImage;
}
}
return null;
}