Add optional below-chat Present Characters panel (#3)

This commit is contained in:
Tremendoussly
2026-03-08 22:58:42 +01:00
committed by GitHub
parent ae9e44eafb
commit 2f98686e60
16 changed files with 593 additions and 143 deletions
+1
View File
@@ -29,6 +29,7 @@ export const defaultSettings = {
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
+5
View File
@@ -376,6 +376,11 @@ export function loadSettings() {
settingsChanged = true;
}
if (extensionSettings.showAlternatePresentCharactersPanel === undefined) {
extensionSettings.showAlternatePresentCharactersPanel = false;
settingsChanged = true;
}
// Save migrated settings
if (settingsChanged) {
saveSettings();
+1
View File
@@ -18,6 +18,7 @@ export let extensionSettings = {
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
+2
View File
@@ -34,6 +34,8 @@
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"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.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",
+2
View File
@@ -35,6 +35,8 @@
"template.settingsModal.display.showInfoBoxNote": "Afficher le lieu, l'heure, la météo et les événements récents.",
"template.settingsModal.display.showPresentCharacters": "Afficher Personnages Présents",
"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.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",
+2
View File
@@ -34,6 +34,8 @@
"template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.",
"template.settingsModal.display.showPresentCharacters": "Показывать персонажей",
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.",
"template.settingsModal.display.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь",
+2
View File
@@ -30,6 +30,8 @@
"template.settingsModal.display.showUserStats": "顯示 user 屬性",
"template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。",
"template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
+2 -1
View File
@@ -9,6 +9,7 @@ import { selected_group, getGroupMembers, groups } from '../../../../../../group
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.js';
@@ -709,7 +710,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`;
// If in Together mode and trackers are enabled, add tracker update instructions
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) {
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled())) {
summaryMessage += `\n--- TRACKER UPDATE ---\n\n`;
summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `;
summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`;
+6 -5
View File
@@ -14,6 +14,7 @@ import {
addLockInstruction
} from './jsonPromptHelpers.js';
import { applyLocks } from './lockManager.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -293,7 +294,7 @@ export function generateTrackerExample() {
}
}
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try {
JSON.parse(committedTrackerData.characterThoughts);
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
@@ -329,7 +330,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
let instructions = '';
// Check if any trackers are enabled
const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts;
const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled();
// Only add tracker instructions if at least one tracker is enabled
if (hasAnyTrackers) {
@@ -360,7 +361,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
if (extensionSettings.showInfoBox) {
enabledTrackers.push('infoBox');
}
if (extensionSettings.showCharacterThoughts) {
if (isPresentCharactersEnabled()) {
enabledTrackers.push('characters');
}
@@ -383,7 +384,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n';
}
if (extensionSettings.showCharacterThoughts) {
if (isPresentCharactersEnabled()) {
instructions += ' "characters": ';
const charactersJSON = buildCharactersJSONInstruction();
// Add 2 spaces to all lines after the first to properly nest within root object
@@ -1061,7 +1062,7 @@ export function generateContextualSummary() {
}
// Add Present Characters tracker data if enabled
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try {
const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName);
if (formatted) {
+13 -137
View File
@@ -4,20 +4,24 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { this_chid, characters } from '../../../../../../../script.js';
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
$thoughtsContainer,
FALLBACK_AVATAR_DATA_URI,
addDebugLog
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { saveChatData, saveSettings, getCurrentMessageSwipeTrackerData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import {
stripBrackets,
extractFieldValue,
toSnakeCase,
getPresentCharactersTrackerData,
resolvePresentCharacterPortrait
} from '../../utils/presentCharacters.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
/**
* Helper to generate lock icon HTML if setting is enabled
@@ -81,80 +85,14 @@ function getStatColor(percentage, lowColor, highColor, lowOpacity = 100, highOpa
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Strips leading and trailing square brackets from a string value.
* Used to clean placeholder notation that AI might include in responses.
* @param {string} value - The value to clean
* @returns {string} Cleaned value without surrounding brackets
*/
function stripBrackets(value) {
if (typeof value !== 'string') return value;
return value.replace(/^\[|\]$/g, '').trim();
}
/**
* Extracts the actual value from a field that might be locked.
* If the field is an object with {value, locked}, returns the value.
* Otherwise returns the field as-is.
* @param {any} fieldValue - The field value (might be string or {value, locked} object)
* @returns {string} The actual string value
*/
function extractFieldValue(fieldValue) {
if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) {
return fieldValue.value || '';
}
return fieldValue || '';
}
/**
* Converts a field name to snake_case for use as JSON key
* Example: "Test Tracker" -> "test_tracker"
* @param {string} name - Field name to convert
* @returns {string} snake_case version
*/
function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Fuzzy name matching that handles:
* - Exact matches: "Sabrina" === "Sabrina"
* - Parenthetical additions: "Sabrina" matches "Sabrina (Margrokha's Avatar)"
* - Title additions: "Sabrina" matches "Princess Sabrina"
* - Word boundaries: "Sabrina" won't match "Sabrina's Mother"
*
* @param {string} cardName - Name from the character card
* @param {string} aiName - Name generated by the AI
* @returns {boolean} True if names match
*/
function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
// 1. Exact match (fast path)
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
// 2. Strip parentheses and match
const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim();
const cardCore = stripParens(cardName).toLowerCase();
const aiCore = stripParens(aiName).toLowerCase();
if (cardCore === aiCore) return true;
// 3. Check if card name appears as complete word in AI name
// Escape special regex characters to prevent "Invalid regular expression" errors
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
/**
* Renders character thoughts (Present Characters) panel.
* Displays character cards with avatars, relationship badges, and traits.
* Includes event listeners for editable character fields.
*/
export function renderThoughts({ preserveScroll = false, useCommittedFallback = true } = {}) {
renderAlternatePresentCharacters({ useCommittedFallback });
if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) {
return;
}
@@ -169,7 +107,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
}
// Don't render if no data exists (e.g., after cache clear)
const thoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null);
const thoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
if (!thoughtsData) {
$thoughtsContainer.html('<div class="rpg-inventory-empty">No character data generated yet</div>');
return;
@@ -193,7 +131,7 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
const hasRelationshipEnabled = relationshipFields.length > 0;
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const characterThoughtsData = lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || '';
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
// console.log('[RPG Companion] renderThoughts - Reading from lastGeneratedData:', JSON.stringify(lastGeneratedData.characterThoughts));
// console.log('[RPG Companion] renderThoughts - Reading from committedTrackerData:', JSON.stringify(committedTrackerData.characterThoughts));
@@ -416,70 +354,8 @@ export function renderThoughts({ preserveScroll = false, useCommittedFallback =
try {
debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name);
// Find character portrait
// Use a base64-encoded SVG placeholder as fallback to avoid 400 errors
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
debugLog(`[RPG Thoughts] Looking up avatar for: ${char.name}`);
// First, check if user manually uploaded a custom avatar
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[char.name]) {
characterPortrait = extensionSettings.npcAvatars[char.name];
debugLog('[RPG Thoughts] Found custom uploaded avatar');
}
// For group chats, search through group members
if (characterPortrait === FALLBACK_AVATAR_DATA_URI && selected_group) {
debugLog('[RPG Thoughts] In group chat, checking group members...');
try {
const groupMembers = getGroupMembers(selected_group);
debugLog('[RPG Thoughts] Group members count:', groupMembers ? groupMembers.length : 0);
if (groupMembers && groupMembers.length > 0) {
const matchingMember = groupMembers.find(member =>
member && member.name && namesMatch(member.name, char.name)
);
if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
debugLog('[RPG Thoughts] Found avatar in group members');
}
}
}
} catch (groupError) {
debugLog('[RPG Thoughts] Error checking group members:', groupError.message);
}
}
// For regular chats or if not found in group, search all characters
if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) {
debugLog('[RPG Thoughts] Searching all characters...');
const matchingCharacter = characters.find(c =>
c && c.name && namesMatch(c.name, char.name)
);
if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
debugLog('[RPG Thoughts] Found avatar in all characters');
}
}
}
// If this is the current character in a 1-on-1 chat, use their portrait
if (this_chid !== undefined && characters[this_chid] &&
characters[this_chid].name && namesMatch(characters[this_chid].name, char.name)) {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
characterPortrait = thumbnailUrl;
debugLog('[RPG Thoughts] Found avatar from current character');
}
}
const characterPortrait = resolvePresentCharacterPortrait(char.name);
debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...');
@@ -0,0 +1,158 @@
import { extensionSettings } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import {
getPresentCharactersTrackerData,
parsePresentCharacters,
resolvePresentCharacterPortrait
} from '../../utils/presentCharacters.js';
const PANEL_ID = 'rpg-alt-present-characters';
function ensureAlternatePresentCharactersPanel() {
let $panel = $(`#${PANEL_ID}`);
if ($panel.length) {
return $panel;
}
$panel = $(`<div id="${PANEL_ID}" class="rpg-alt-present-characters" style="display:none;"></div>`);
const $sendForm = $('#send_form');
const $sheld = $('#sheld');
const $chat = $sheld.find('#chat');
if ($sendForm.length) {
$sendForm.before($panel);
} else if ($chat.length) {
$chat.after($panel);
} else if ($sheld.length) {
$sheld.append($panel);
} else {
$('body').append($panel);
}
return $panel;
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function hexToRgba(hex, opacity = 100) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const a = opacity / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
export function removeAlternatePresentCharactersPanel() {
$(`#${PANEL_ID}`).remove();
}
export function syncAlternatePresentCharactersTheme() {
const $panel = $(`#${PANEL_ID}`);
if (!$panel.length) {
return;
}
const theme = extensionSettings.theme || 'default';
$panel.css({
'--rpg-bg': '',
'--rpg-accent': '',
'--rpg-text': '',
'--rpg-highlight': '',
'--rpg-border': '',
'--rpg-shadow': ''
});
if (theme === 'default') {
$panel.removeAttr('data-theme');
return;
}
$panel.attr('data-theme', theme);
if (theme === 'custom') {
const colors = extensionSettings.customColors || {};
const bgColor = hexToRgba(colors.bg || '#1a1a2e', colors.bgOpacity ?? 100);
const accentColor = hexToRgba(colors.accent || '#16213e', colors.accentOpacity ?? 100);
const textColor = hexToRgba(colors.text || '#eaeaea', colors.textOpacity ?? 100);
const highlightColor = hexToRgba(colors.highlight || '#e94560', colors.highlightOpacity ?? 100);
const shadowColor = hexToRgba(colors.highlight || '#e94560', (colors.highlightOpacity ?? 100) * 0.5);
$panel.css({
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
});
}
}
export function renderAlternatePresentCharacters({ useCommittedFallback = true } = {}) {
if (!extensionSettings.enabled || !extensionSettings.showAlternatePresentCharactersPanel) {
removeAlternatePresentCharactersPanel();
return;
}
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
if (!characterThoughtsData) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const presentCharacters = parsePresentCharacters(characterThoughtsData);
if (presentCharacters.length === 0) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters';
let html = `
<div class="rpg-alt-present-characters__header">
<div class="rpg-alt-present-characters__title">
<i class="fa-solid fa-users" aria-hidden="true"></i>
<span>${escapeHtml(title)}</span>
</div>
<div class="rpg-alt-present-characters__count">${presentCharacters.length}</div>
</div>
<div class="rpg-alt-present-characters__scroll">
<div class="rpg-alt-present-characters__track">
`;
for (const character of presentCharacters) {
const portrait = resolvePresentCharacterPortrait(character.name);
const name = escapeHtml(character.name || '');
html += `
<div class="rpg-alt-present-character" data-character-name="${name}" title="${name}">
<div class="rpg-alt-present-character__portrait">
<img src="${portrait}" alt="${name}" loading="lazy" onerror="this.style.opacity='0.5';this.onerror=null;" />
</div>
<div class="rpg-alt-present-character__meta">
<div class="rpg-alt-present-character__name">${name}</div>
</div>
</div>
`;
}
html += `
</div>
</div>
`;
const $panel = ensureAlternatePresentCharactersPanel();
$panel.html(html).show();
syncAlternatePresentCharactersTheme();
}
+5
View File
@@ -4,6 +4,7 @@
*/
import { extensionSettings, $panelContainer } from '../../core/state.js';
import { syncAlternatePresentCharactersTheme } from './alternatePresentCharacters.js';
/**
* Converts hex color and opacity percentage to rgba string
@@ -96,6 +97,8 @@ export function applyTheme() {
$thoughtPanel.attr('data-theme', theme);
}
}
syncAlternatePresentCharactersTheme();
}
/**
@@ -150,6 +153,8 @@ export function applyCustomTheme() {
if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles);
}
syncAlternatePresentCharactersTheme();
}
/**
+237
View File
@@ -0,0 +1,237 @@
import { this_chid, characters } from '../../../../../../script.js';
import { selected_group, getGroupMembers } from '../../../../../group-chats.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
FALLBACK_AVATAR_DATA_URI
} from '../core/state.js';
import { getSafeThumbnailUrl } from './avatars.js';
export function stripBrackets(value) {
if (typeof value !== 'string') return value;
return value.replace(/^\[|\]$/g, '').trim();
}
export function extractFieldValue(fieldValue) {
if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) {
return fieldValue.value || '';
}
return fieldValue || '';
}
export function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
export function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim();
const cardCore = stripParens(cardName).toLowerCase();
const aiCore = stripParens(aiName).toLowerCase();
if (cardCore === aiCore) return true;
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
export function isPresentCharactersEnabled() {
return !!(extensionSettings.showCharacterThoughts || extensionSettings.showAlternatePresentCharactersPanel);
}
export function getPresentCharactersTrackerData({ useCommittedFallback = true } = {}) {
return lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || '';
}
export function parsePresentCharacters(characterThoughtsData, { enabledFields = [], enabledCharStats = [] } = {}) {
if (!characterThoughtsData) {
return [];
}
let presentCharacters = [];
try {
const parsed = typeof characterThoughtsData === 'string'
? JSON.parse(characterThoughtsData)
: characterThoughtsData;
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) {
presentCharacters = charactersArray.map(char => {
const character = {
name: char.name,
emoji: char.emoji || '👤'
};
if (char.details) {
for (const field of enabledFields) {
if (char.details[field.name] !== undefined) {
character[field.name] = stripBrackets(char.details[field.name]);
} else {
const fieldKey = toSnakeCase(field.name);
if (char.details[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char.details[fieldKey]);
}
}
}
}
for (const field of enabledFields) {
if (character[field.name] === undefined) {
const fieldKey = toSnakeCase(field.name);
if (char[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char[fieldKey]);
}
}
}
if (char.Relationship) {
character.Relationship = stripBrackets(char.Relationship);
} else if (char.relationship) {
character.Relationship = stripBrackets(char.relationship.status || char.relationship);
}
if (char.thoughts) {
character.ThoughtsContent = stripBrackets(char.thoughts.content || char.thoughts);
}
if (char.stats && enabledCharStats.length > 0) {
if (Array.isArray(char.stats)) {
for (const statObj of char.stats) {
if (statObj.name && statObj.value !== undefined) {
const matchingStat = enabledCharStats.find(s => s.name === statObj.name);
if (matchingStat) {
character[statObj.name] = statObj.value;
}
}
}
} else {
for (const stat of enabledCharStats) {
if (char.stats[stat.name] !== undefined) {
character[stat.name] = char.stats[stat.name];
}
}
}
}
return character;
});
}
} catch {
// Fall back to the legacy text format below.
}
if (presentCharacters.length > 0 || typeof characterThoughtsData !== 'string') {
return presentCharacters;
}
const lines = characterThoughtsData.split('\n');
let currentCharacter = null;
for (const line of lines) {
if (!line.trim()
|| line.includes('Present Characters')
|| line.includes('---')
|| line.trim().startsWith('```')
|| line.trim() === '- …'
|| line.includes('(Repeat the format')) {
continue;
}
if (line.trim().startsWith('- ')) {
const name = line.trim().substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') {
currentCharacter = { name };
presentCharacters.push(currentCharacter);
} else {
currentCharacter = null;
}
} else if (line.trim().startsWith('Details:') && currentCharacter) {
const detailsContent = line.substring(line.indexOf(':') + 1).trim();
const parts = detailsContent.split('|').map(p => p.trim());
if (parts.length > 0) {
currentCharacter.emoji = parts[0];
}
for (let i = 0; i < enabledFields.length && i + 1 < parts.length; i++) {
currentCharacter[enabledFields[i].name] = parts[i + 1];
}
} else if (line.trim().startsWith('Relationship:') && currentCharacter) {
currentCharacter.Relationship = line.substring(line.indexOf(':') + 1).trim();
} else if (line.trim().startsWith('Stats:') && currentCharacter && enabledCharStats.length > 0) {
const statsContent = line.substring(line.indexOf(':') + 1).trim();
const statParts = statsContent.split('|').map(p => p.trim());
for (const statPart of statParts) {
const statMatch = statPart.match(/^(.+?):\s*(\d+)%$/);
if (statMatch) {
currentCharacter[statMatch[1].trim()] = parseInt(statMatch[2], 10);
}
}
}
}
return presentCharacters;
}
export function resolvePresentCharacterPortrait(name) {
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
if (!name) {
return characterPortrait;
}
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[name]) {
return extensionSettings.npcAvatars[name];
}
if (selected_group) {
try {
const groupMembers = getGroupMembers(selected_group);
const matchingMember = groupMembers?.find(member =>
member && member.name && namesMatch(member.name, name)
);
if (matchingMember?.avatar && matchingMember.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
} catch {
// Ignore avatar lookup issues and continue through fallback chain.
}
}
if (characters?.length > 0) {
const matchingCharacter = characters.find(character =>
character && character.name && namesMatch(character.name, name)
);
if (matchingCharacter?.avatar && matchingCharacter.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
}
if (this_chid !== undefined && characters[this_chid]?.name && namesMatch(characters[this_chid].name, name)) {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
return characterPortrait;
}