Add optional below-chat Present Characters panel (#3)
This commit is contained in:
@@ -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) + '...');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user