Release v3.0.0 - Major update with JSON format, lock/unlock trackers, reorganized UI, colored dialogues, editable prompts, and numerous bug fixes

This commit is contained in:
Spicy_Marinara
2026-01-07 17:22:22 +01:00
parent 8df6548e0b
commit c3cdac24c6
46 changed files with 6241 additions and 3571 deletions
+3 -19
View File
@@ -174,10 +174,10 @@ export async function generateAvatarsForCharacters(characterNames, onStarted = n
// Generate LLM prompt for this character
const prompt = await generateAvatarPrompt(characterName);
// Generate the image using the prompt
await generateSingleAvatar(characterName, prompt);
pendingGenerations.delete(characterName);
// Small delay between generations to avoid overwhelming the API
@@ -220,16 +220,6 @@ export async function regenerateAvatar(characterName) {
delete sessionAvatarPrompts[characterName];
}
// Save current preset and switch to RPG Companion Trackers if enabled
let originalPresetName = null;
if (extensionSettings.useSeparatePreset) {
originalPresetName = await getCurrentPresetName();
if (originalPresetName) {
console.log(`[RPG Avatar] Switching from "${originalPresetName}" to RPG Companion Trackers preset`);
await switchToPreset('RPG Companion Trackers');
}
}
try {
// Generate new LLM prompt
const prompt = await generateAvatarPrompt(characterName);
@@ -237,12 +227,6 @@ export async function regenerateAvatar(characterName) {
// Generate the avatar
return await generateSingleAvatar(characterName, prompt);
} finally {
// Restore original preset if we switched
if (originalPresetName && extensionSettings.useSeparatePreset) {
console.log(`[RPG Avatar] Restoring original preset: "${originalPresetName}"`);
await switchToPreset(originalPresetName);
}
// Remove from pending when done
pendingGenerations.delete(characterName);
}
@@ -327,7 +311,7 @@ async function generateSingleAvatar(characterName, prompt = null) {
if (!prompt) {
prompt = sessionAvatarPrompts[characterName];
}
if (!prompt) {
console.log(`[RPG Avatar] No LLM prompt for ${characterName}, using fallback prompt`);
prompt = buildFallbackPrompt(characterName);
+83
View File
@@ -114,3 +114,86 @@ export async function ensureHtmlCleaningRegex(st_extension_settings, saveSetting
// Don't throw - this is a nice-to-have feature
}
}
/**
* Automatically imports a regex script to clean tracker JSON from outgoing prompts.
* This is useful when switching from together mode to separate mode mid-roleplay,
* as it prevents old tracker JSON from chat history being sent to the AI.
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export async function ensureTrackerCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
// Validate extension settings structure
if (!st_extension_settings || typeof st_extension_settings !== 'object') {
console.warn('[RPG Companion] Invalid extension_settings object, skipping tracker cleaning regex import');
return;
}
// Check if the tracker cleaning regex already exists
const scriptName = 'Clean RPG Trackers (From Outgoing Prompt)';
const existingScripts = st_extension_settings?.regex || [];
// Validate regex array
if (!Array.isArray(existingScripts)) {
console.warn('[RPG Companion] extension_settings.regex is not an array, resetting to empty array');
st_extension_settings.regex = [];
}
const alreadyExists = existingScripts.some(script =>
script && typeof script === 'object' && script.scriptName === scriptName
);
if (alreadyExists) {
console.log('[RPG Companion] Tracker cleaning regex already exists, skipping import');
return;
}
// Generate a UUID for the script
const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// Create the regex script to remove ```json...``` blocks containing tracker data
// This regex matches markdown code blocks with "json" language tag
const regexScript = {
id: uuidv4(),
scriptName: scriptName,
findRegex: '/```json\\s*\\n\\{[\\s\\S]*?(?:\"userStats\"|\"infoBox\"|\"characters\")[\\s\\S]*?\\}\\s*\\n```/gm',
replaceString: '',
trimStrings: [],
placement: [2], // 2 = Input (affects outgoing prompt)
disabled: false,
markdownOnly: false,
promptOnly: true,
runOnEdit: true,
substituteRegex: 0,
minDepth: null,
maxDepth: null
};
// Add to global regex scripts
if (!Array.isArray(st_extension_settings.regex)) {
st_extension_settings.regex = [];
}
st_extension_settings.regex.push(regexScript);
// Save the changes
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
} else {
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save tracker cleaning regex');
}
console.log('[RPG Companion] ✅ Tracker cleaning regex imported successfully');
} catch (error) {
console.error('[RPG Companion] Failed to import tracker cleaning regex:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - this is a nice-to-have feature
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* JSON Cleaning Module
* Automatically registers a regex script to strip tracker JSON from Together mode output
*/
/**
* Registers an output transformation regex to remove tracker JSON from messages
* This uses SillyTavern's built-in regex system to transform text BEFORE display
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export async function ensureJsonCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
// Validate extension settings structure
if (!st_extension_settings || typeof st_extension_settings !== 'object') {
console.warn('[RPG Companion] Invalid extension_settings object, skipping JSON cleaning regex');
return;
}
// Check if the JSON cleaning regex already exists
const scriptName = 'RPG Companion - Remove Tracker JSON (Together Mode)';
const existingScripts = st_extension_settings?.regex || [];
// Validate regex array
if (!Array.isArray(existingScripts)) {
console.warn('[RPG Companion] extension_settings.regex is not an array, resetting to empty array');
st_extension_settings.regex = [];
}
const alreadyExists = existingScripts.some(script =>
script && script.scriptName && script.scriptName === scriptName
);
if (alreadyExists) {
console.log('[RPG Companion] JSON cleaning regex already exists, skipping import');
return;
}
console.log('[RPG Companion] Importing JSON cleaning regex for Together mode...');
// Generate a UUID for the script
const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
// Create the regex script object for cleaning JSON tracker data
// This regex matches ```json...``` code blocks containing tracker data
// The prompt now explicitly instructs models to use this format
const regexScript = {
id: uuidv4(),
scriptName: scriptName,
// Match ```json...``` code blocks (non-greedy, multiline)
// This is now the guaranteed format since prompts instruct models to use code blocks
findRegex: '/```json\\s*[\\s\\S]*?```/gi',
replaceString: '',
trimStrings: [],
placement: [0], // 0 = Output (transforms after generation, before display)
disabled: false,
markdownOnly: false,
promptOnly: false, // Apply to both prompts and outputs
runOnEdit: true,
substituteRegex: 0,
minDepth: null,
maxDepth: null
};
// Add to global regex scripts
if (!Array.isArray(st_extension_settings.regex)) {
st_extension_settings.regex = [];
}
st_extension_settings.regex.push(regexScript);
// Save the changes
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
} else {
console.warn('[RPG Companion] saveSettingsDebounced is not a function, cannot save JSON cleaning regex');
}
console.log('[RPG Companion] ✅ JSON cleaning regex imported successfully');
console.log('[RPG Companion] This regex will automatically remove tracker JSON from Together mode messages');
} catch (error) {
console.error('[RPG Companion] Failed to import JSON cleaning regex:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
// Don't throw - continue without it
}
}
/**
* Removes the JSON cleaning regex if it exists
* Useful when switching to separate mode or disabling the feature
* @param {Object} st_extension_settings - SillyTavern extension settings object
* @param {Function} saveSettingsDebounced - Function to save settings
*/
export function removeJsonCleaningRegex(st_extension_settings, saveSettingsDebounced) {
try {
if (!st_extension_settings?.regex || !Array.isArray(st_extension_settings.regex)) {
return;
}
const scriptName = 'RPG Companion - Remove Tracker JSON (Together Mode)';
const initialLength = st_extension_settings.regex.length;
st_extension_settings.regex = st_extension_settings.regex.filter(script =>
!script || !script.scriptName || script.scriptName !== scriptName
);
if (st_extension_settings.regex.length < initialLength) {
console.log('[RPG Companion] Removed JSON cleaning regex');
if (typeof saveSettingsDebounced === 'function') {
saveSettingsDebounced();
}
}
} catch (error) {
console.error('[RPG Companion] Failed to remove JSON cleaning regex:', error);
}
}
-267
View File
@@ -1,267 +0,0 @@
/**
* Lorebook Limiter Module
* Adds maximum activation limit to SillyTavern's World Info system
*/
import { eventSource, event_types } from '../../../../../../../script.js';
let maxActivations = 0; // 0 = unlimited
let settingsInitialized = false;
let activatedEntriesThisGeneration = [];
/**
* Initialize the lorebook limiter
*/
export function initLorebookLimiter() {
console.log('[Lorebook Limiter] Initializing...');
// Load saved setting
const saved = localStorage.getItem('rpg_max_lorebook_activations');
if (saved !== null) {
maxActivations = parseInt(saved, 10);
}
// Wait for World Info settings to be ready
eventSource.on('worldInfoSettings', () => {
setTimeout(() => {
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 100);
});
// Try when the WI drawer is opened
const tryInjectOnClick = () => {
const wiButton = document.querySelector('#WIDrawerIcon');
if (wiButton) {
wiButton.addEventListener('click', () => {
setTimeout(() => {
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 300);
});
console.log('[Lorebook Limiter] Attached to WI drawer button');
}
};
// Also try on app ready
eventSource.on('app_ready', () => {
setTimeout(() => {
tryInjectOnClick();
if (!settingsInitialized) {
injectMaxActivationsUI();
settingsInitialized = true;
}
}, 1000);
});
// Patch the world info activation system
patchWorldInfoActivation();
}
/**
* Inject the Maximum Activations UI into World Info settings
*/
function injectMaxActivationsUI() {
console.log('[Lorebook Limiter] Injecting UI...');
// Check if already injected
if (document.querySelector('#rpg-max-lorebook-activations-container')) {
console.log('[Lorebook Limiter] UI already injected');
return;
}
// Find the Memory Recollection button - we'll add our UI right after it
const memoryButton = document.querySelector('.rpg-memory-recollection-btn');
if (!memoryButton) {
console.log('[Lorebook Limiter] Memory Recollection button not found yet');
return;
}
const container = memoryButton.parentElement;
if (!container) {
console.log('[Lorebook Limiter] Could not find button container');
return;
}
console.log('[Lorebook Limiter] Found Memory Recollection button, injecting slider after it');
// Create the UI - styled to match the extension's theme
const settingHTML = `
<div id="rpg-max-lorebook-activations-container" class="rpg-lorebook-limiter-container">
<label class="rpg-lorebook-limiter-label">
<span class="rpg-lorebook-limiter-title">Max Lorebook Activations</span>
<input type="number"
id="rpg-max-activations-input"
class="rpg-lorebook-limiter-input"
min="0"
max="9999"
step="1"
value="${maxActivations}"
placeholder="0 = unlimited" />
</label>
<small class="rpg-lorebook-limiter-hint">Limit entries per generation (0 = unlimited)</small>
</div>
`;
// Insert after the Memory Recollection button
memoryButton.insertAdjacentHTML('afterend', settingHTML);
// Add event listener
const input = document.querySelector('#rpg-max-activations-input');
if (input) {
input.addEventListener('input', (e) => {
let value = parseInt(e.target.value, 10);
if (isNaN(value) || value < 0) value = 0;
if (value > 9999) value = 9999;
maxActivations = value;
e.target.value = value;
localStorage.setItem('rpg_max_lorebook_activations', value.toString());
console.log(`[Lorebook Limiter] Max activations set to: ${value}`);
});
console.log('[Lorebook Limiter] ✅ UI injected successfully');
}
}
/**
* Patch the world info activation system to enforce the limit
*/
function patchWorldInfoActivation() {
console.log('[Lorebook Limiter] Setting up activation limiter...');
// We need to intercept at the module level
// Use a Proxy on the module loader
const originalDefine = window.define;
const originalRequire = window.require;
// Try multiple approaches to hook into the WI system
const attemptPatch = () => {
// Approach 1: Direct window access
if (window.getWorldInfoPrompt) {
const original = window.getWorldInfoPrompt;
window.getWorldInfoPrompt = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result) {
// Count entries in the worldInfoString
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${lines.length} WI lines to ${maxActivations}`);
// Trim the strings
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
result.worldInfoString = result.worldInfoBefore;
console.log(`[Lorebook Limiter] ✅ Limited from ${lines.length} to ${limitedLines.length} entries`);
}
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched window.getWorldInfoPrompt');
return true;
}
// Approach 2: Through SillyTavern context
if (window.SillyTavern?.getContext) {
const ctx = window.SillyTavern.getContext();
if (ctx.getWorldInfoPrompt) {
const original = ctx.getWorldInfoPrompt;
ctx.getWorldInfoPrompt = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result) {
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${lines.length} WI entries to ${maxActivations}`);
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
result.worldInfoString = result.worldInfoBefore;
}
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().getWorldInfoPrompt');
return true;
}
// Try checkWorldInfo instead
if (ctx.checkWorldInfo) {
const original = ctx.checkWorldInfo;
ctx.checkWorldInfo = async function(...args) {
const result = await original.apply(this, args);
if (maxActivations > 0 && result?.allActivatedEntries?.size > maxActivations) {
console.log(`[Lorebook Limiter] Limiting ${result.allActivatedEntries.size} entries to ${maxActivations}`);
// Keep only first N entries
const entries = Array.from(result.allActivatedEntries.entries());
result.allActivatedEntries = new Map(entries.slice(0, maxActivations));
// Also limit the string output
const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim());
if (lines.length > maxActivations) {
const limitedLines = lines.slice(0, maxActivations);
result.worldInfoBefore = limitedLines.join('\n');
result.worldInfoAfter = '';
}
console.log(`[Lorebook Limiter] ✅ Limited to ${result.allActivatedEntries.size} entries`);
}
return result;
};
console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().checkWorldInfo');
return true;
}
}
return false;
};
// Try immediately
if (!attemptPatch()) {
// Retry after delays
setTimeout(() => attemptPatch() || setTimeout(() => attemptPatch(), 2000), 1000);
}
}
/**
* Update the maximum activations limit
*/
export function setMaxActivations(value) {
maxActivations = parseInt(value, 10);
localStorage.setItem('rpg_max_lorebook_activations', value.toString());
// Update UI if it exists
const valueDisplay = document.querySelector('#rpg-max-activations-value');
const slider = document.querySelector('#rpg-max-activations-slider');
if (valueDisplay) {
valueDisplay.textContent = value;
}
if (slider) {
slider.value = value;
}
}
/**
* Get current maximum activations limit
*/
export function getMaxActivations() {
return maxActivations;
}
-843
View File
@@ -1,843 +0,0 @@
/**
* Memory Recollection Module
* Handles generation of lorebook entries from chat history
*/
import { chat, characters, this_chid, generateRaw, substituteParams, eventSource, event_types } from '../../../../../../../script.js';
import { selected_group } from '../../../../../../group-chats.js';
import { extensionSettings, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { checkWorldInfo, createNewWorldInfo, openWorldInfoEditor, saveWorldInfo, setWorldInfoSettings } from '../../../../../../world-info.js';
/**
* Helper to log to both console and debug logs array
*/
function debugLog(message, data = null) {
if (data !== null && data !== undefined) {
console.log(message, data);
} else {
console.log(message);
}
if (extensionSettings.debugMode) {
addDebugLog(message, data);
}
}
/**
* Get or create the Memory Recollection lorebook
* @returns {Promise<string>} The UID of the Memory Recollection lorebook
*/
async function getOrCreateMemoryLorebook() {
const lorebookName = 'Memory Recollection';
try {
debugLog('[Memory Recollection] Checking for existing lorebook...');
// Use checkWorldInfo to see if it exists
const exists = await checkWorldInfo(lorebookName);
if (exists) {
debugLog('[Memory Recollection] Found existing lorebook:', lorebookName);
return lorebookName;
}
// Create new lorebook using SillyTavern's imported function
debugLog('[Memory Recollection] Creating new Memory Recollection lorebook');
// Call the imported createNewWorldInfo function
await createNewWorldInfo(lorebookName, true);
debugLog('[Memory Recollection] Created lorebook:', lorebookName);
// Wait for the file system to settle
await new Promise(resolve => setTimeout(resolve, 500));
return lorebookName;
} catch (error) {
console.error('[Memory Recollection] Error in getOrCreateMemoryLorebook:', error);
throw error;
}
}
/**
* Create the constant "Relevant Memories:" header entry
* @param {string} lorebookUid - The UID of the lorebook
* @returns {Object} The header entry object
*/
function createConstantHeaderEntry() {
const entry = {
uid: 1, // Fixed UID so it's always first
key: [],
keysecondary: [],
comment: 'Relevant Memories Header',
content: 'Relevant Memories:',
constant: true, // Always inserted
vectorized: false,
selective: false,
selectiveLogic: 0,
addMemo: false,
order: 99, // First in order
position: 4, // at Depth
disable: false,
ignoreBudget: false,
excludeRecursion: false,
preventRecursion: false,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 1, // Insertion depth
outletName: '',
group: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0, // System role
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: 0,
characterFilter: {
isExclude: false,
names: [],
tags: []
}
};
debugLog('[Memory Recollection] Created constant header entry');
return entry;
}
/**
* Save a world info entry to a lorebook
* @param {string} lorebookUid - The filename/UID of the lorebook
* @param {Object} entry - The entry data
*/
async function saveWorldInfoEntry(lorebookUid, entry) {
try {
debugLog('[Memory Recollection] Saving entry to lorebook:', lorebookUid);
// Open the world info editor for this lorebook to load its data
await openWorldInfoEditor(lorebookUid);
// Wait for it to load
await new Promise(resolve => setTimeout(resolve, 500));
// Now access the loaded world info data
const worldInfo = window.world_info;
debugLog('[Memory Recollection] World info after opening:', {
type: typeof worldInfo,
isArray: Array.isArray(worldInfo),
hasEntries: worldInfo?.entries !== undefined,
keys: worldInfo ? Object.keys(worldInfo).slice(0, 10) : null
});
// Try different structures - it might be an array or might have different properties
let entries;
if (worldInfo && typeof worldInfo === 'object') {
if (worldInfo.entries) {
entries = worldInfo.entries;
} else if (Array.isArray(worldInfo)) {
// If it's an array, convert to entries object
entries = {};
worldInfo.forEach((e, i) => {
if (e && e.uid) {
entries[e.uid] = e;
}
});
}
}
if (!entries) {
entries = {};
}
// Add the entry
entries[entry.uid] = entry;
debugLog('[Memory Recollection] Entry added, saving world info...');
// Save using the imported saveWorldInfo function
// Pass the entries as the data structure
await saveWorldInfo(lorebookUid, { entries });
debugLog('[Memory Recollection] Entry saved successfully');
return { success: true };
} catch (error) {
console.error('[Memory Recollection] Error saving entry:', error);
throw error;
}
}
/**
* Save multiple world info entries to a lorebook at once
* @param {string} lorebookUid - The filename/UID of the lorebook
* @param {Array} newEntries - Array of entry objects to add
*/
async function saveWorldInfoEntries(lorebookUid, newEntries) {
try {
debugLog(`[Memory Recollection] Saving ${newEntries.length} entries to lorebook:`, lorebookUid);
// Open the world info editor for this lorebook to load its data
await openWorldInfoEditor(lorebookUid);
// Wait for it to load
await new Promise(resolve => setTimeout(resolve, 500));
// Now access the loaded world info data
const worldInfo = window.world_info;
// Try different structures - it might be an array or might have different properties
let entries = {};
if (worldInfo && typeof worldInfo === 'object') {
if (worldInfo.entries) {
entries = { ...worldInfo.entries }; // Clone existing entries
} else if (Array.isArray(worldInfo)) {
// If it's an array, convert to entries object
worldInfo.forEach((e, i) => {
if (e && e.uid) {
entries[e.uid] = e;
}
});
}
}
// Add all new entries
for (const entry of newEntries) {
entries[entry.uid] = entry;
}
debugLog(`[Memory Recollection] ${newEntries.length} entries added, total entries: ${Object.keys(entries).length}, saving...`);
// Save using the imported saveWorldInfo function
await saveWorldInfo(lorebookUid, { entries });
debugLog('[Memory Recollection] All entries saved successfully');
return { success: true };
} catch (error) {
console.error('[Memory Recollection] Error saving entries:', error);
throw error;
}
}
/**
* Generate memory recollection prompt for a batch of messages
* @param {Array} messages - Array of chat messages to process
* @param {boolean} isUpdate - Whether this is updating existing memories (true) or initial generation (false)
* @returns {string} The prompt for the AI
*/
function generateMemoryPrompt(messages, isUpdate = false) {
const context = messages.map((msg, idx) => {
const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}';
const role = msg.is_user ? '[PLAYER]' : '[CHARACTER]';
return `[Message ${idx + 1}] ${role} ${sender}: ${msg.mes}`;
}).join('\n\n');
// Get list of CHARACTER participants (exclude {{user}} - they're the player, not a character)
const participants = new Set();
messages.forEach(msg => {
const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}';
if (!msg.is_user) { // Only add non-user (character) participants
participants.add(sender);
}
});
const characterList = Array.from(participants).join(', ');
const instruction = isUpdate
? 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating additional memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.'
: 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.';
return `${instruction}
Characters in this conversation (excluding {{user}} who is the player): ${characterList}
NOTE: In the conversation below, messages are marked with [PLAYER] for {{user}} messages and [CHARACTER] for NPC messages.
Here is the conversation to create memories from:
<conversation>
${context}
</conversation>
Create lorebook entries in the following JSON format. Each entry should be a 1-2 sentence reminder from a character's perspective.
Format each entry as:
{
"characters": ["Character1", "Character2"],
"memory": "Character1 and Character2 remember that [event or detail]",
"keywords": ["keyword1", "keyword2", "keyword3"]
}
Examples:
<examples>
{
"characters": ["Sabrina"],
"memory": "Sabrina remembers she went on a date with {{user}} on Saturday. They ate chocolate pastries together.",
"keywords": ["date", "saturday", "pastries"]
},
{
"characters": ["Dottore", "Arlecchino", "Pantalone"],
"memory": "Dottore, Arlecchino, and Pantalone remember they attended a party together at the mansion.",
"keywords": ["party", "mansion", "gathering"]
}
</examples>
IMPORTANT:
- Only create entries for significant moments worth remembering.
- Keep memories concise (1-2 sentences maximum).
- Use third person perspective: "{name} remembers..."
- Choose 3 specific, relevant keywords per entry.
- ONLY assign memories to CHARACTERS (NPCs) - NEVER include {{user}} in the "characters" array.
- {{user}} is the player, not a character, so they should NEVER be in the characters list.
- Only characters who were ACTUALLY PRESENT in that specific scene/moment should remember it.
- If multiple characters share the memory, list all of them in the "characters" array.
- If known, include details such as dates, locations, and other relevant context in the memories.
Return ONLY a JSON array of memory objects, nothing else:`;
}
/**
* Parse the AI response to extract memory entries
* @param {string} response - The AI's response
* @returns {Array<Object>} Array of parsed memory entries
*/
function parseMemoryResponse(response) {
try {
// Try to extract JSON from code blocks
const jsonMatch = response.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/);
const jsonString = jsonMatch ? jsonMatch[1] : response;
// Parse JSON
const memories = JSON.parse(jsonString.trim());
if (!Array.isArray(memories)) {
throw new Error('Response is not an array');
}
debugLog('[Memory Recollection] Parsed memories:', memories);
return memories;
} catch (error) {
debugLog('[Memory Recollection] Failed to parse response:', error);
console.error('[Memory Recollection] Parse error:', error);
console.error('[Memory Recollection] Raw response:', response);
return [];
}
}
/**
* Create a world info entry from a memory object
* @param {string} lorebookUid - The UID of the lorebook
* @param {Object} memory - The memory object
* @param {number} index - The index for ordering
*/
async function createMemoryEntry(lorebookUid, memory, index) {
const { characters: characterList, memory: content, keywords } = memory;
// Handle character filter - just use the character names directly
let characterNames = [];
if (Array.isArray(characterList) && characterList.length > 0) {
// New format: array of character names
characterNames = characterList.map(name => name.trim());
debugLog(`[Memory Recollection] Character names for filter:`, characterNames);
} else if (typeof characterList === 'string' && characterList.trim() !== '') {
// Legacy string format or comma-separated - parse it
characterNames = characterList.split(',').map(n => n.trim()).filter(n => n !== '');
debugLog(`[Memory Recollection] Character names for filter:`, characterNames);
}
const entry = {
uid: Date.now() + index, // Simple UID generation
key: keywords || [],
keysecondary: [],
comment: `Memory: ${characterNames.join(', ')}`,
content: content,
constant: false,
vectorized: false,
selective: true,
selectiveLogic: 0,
addMemo: false,
order: 100,
position: 4, // at Depth
disable: false,
ignoreBudget: false,
excludeRecursion: false,
preventRecursion: false,
matchPersonaDescription: false,
matchCharacterDescription: false,
matchCharacterPersonality: false,
matchCharacterDepthPrompt: false,
matchScenario: false,
matchCreatorNotes: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 1, // Insertion depth
outletName: '',
group: '',
groupOverride: false,
groupWeight: 100,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0, // 0 = System role (matching the example)
sticky: 0,
cooldown: 0,
delay: 0,
triggers: [],
displayIndex: index + 1,
characterFilter: {
isExclude: false,
names: characterNames, // Array of character names
tags: []
},
extensions: {
position: 4, // at Depth
depth: 1,
role: 1
}
};
debugLog(`[Memory Recollection] Created entry for ${characterNames.join(', ')} with character filter:`, characterNames);
return entry; // Return instead of saving
}
/**
* Process a batch of messages and generate memory entries
* @param {Array} messages - Array of messages to process
* @param {string} lorebookUid - The UID of the lorebook
* @param {boolean} isUpdate - Whether this is an update (true) or initial generation (false)
* @param {number} startIndex - Starting index for entry ordering
* @returns {Promise<Array>} Array of created entries
*/
async function processBatch(messages, lorebookUid, isUpdate, startIndex) {
debugLog(`[Memory Recollection] Processing batch of ${messages.length} messages (isUpdate: ${isUpdate})`);
const prompt = generateMemoryPrompt(messages, isUpdate);
// Generate using SillyTavern's generateRaw
const response = await generateRaw(prompt, '', false, false);
if (!response) {
throw new Error('No response from AI');
}
// Parse the response
const memories = parseMemoryResponse(response);
if (memories.length === 0) {
debugLog('[Memory Recollection] No memories extracted from this batch');
// Return -1 to signal parse failure (vs 0 for valid but empty response)
throw new Error('Failed to parse memories from AI response. The response may be invalid or the service may be unavailable.');
}
// Create entries for each memory (but don't save yet)
const entries = [];
for (let i = 0; i < memories.length; i++) {
const entry = await createMemoryEntry(lorebookUid, memories[i], startIndex + i);
entries.push(entry);
}
debugLog(`[Memory Recollection] Created ${entries.length} entries from batch`);
return entries;
}
/**
* Main function to start memory recollection process
* @param {Function} onProgress - Callback for progress updates (current, total)
* @param {Function} onComplete - Callback when complete
* @param {Function} onError - Callback for errors
*/
export async function startMemoryRecollection(onProgress, onComplete, onError) {
try {
debugLog('[Memory Recollection] Starting memory recollection process');
// Get or create the lorebook
const lorebookUid = await getOrCreateMemoryLorebook();
// Get messages to process count from settings
const messagesToProcess = extensionSettings.memoryMessagesToProcess || 16;
// Check if this is an update (lorebook already exists with entries)
const world_info = window.world_info;
const lorebook = world_info.globalSelect?.find(book => book.uid === lorebookUid);
const existingEntryCount = lorebook?.entries ? Object.keys(lorebook.entries).length : 0;
const isUpdate = existingEntryCount > 1; // More than just the header
let messagesToProcessArray;
if (isUpdate) {
// Process only the last batch
const totalMessages = chat.length;
const startIdx = Math.max(0, totalMessages - messagesToProcess);
messagesToProcessArray = chat.slice(startIdx);
debugLog(`[Memory Recollection] Update mode: Processing last ${messagesToProcess} messages`);
} else {
// Process entire chat in batches
messagesToProcessArray = chat;
debugLog(`[Memory Recollection] Initial mode: Processing all ${chat.length} messages`);
}
const totalBatches = Math.ceil(messagesToProcessArray.length / messagesToProcess);
let entryIndex = existingEntryCount;
const allEntries = []; // Accumulate all entries here
for (let i = 0; i < totalBatches; i++) {
const batchStart = i * messagesToProcess;
const batchEnd = Math.min(batchStart + messagesToProcess, messagesToProcessArray.length);
const batch = messagesToProcessArray.slice(batchStart, batchEnd);
onProgress(i + 1, totalBatches);
try {
const batchEntries = await processBatch(batch, lorebookUid, isUpdate && i === 0, entryIndex);
allEntries.push(...batchEntries); // Add to accumulator
entryIndex += batchEntries.length;
} catch (error) {
// Batch failed - ask user if they want to retry
debugLog('[Memory Recollection] Batch failed:', error.message);
const retry = await new Promise(resolve => {
const retryModal = document.createElement('div');
retryModal.className = 'rpg-memory-modal-overlay';
retryModal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3>⚠️ Generation Failed</h3>
</div>
<div class="rpg-memory-modal-body">
<p><strong>Error:</strong> ${error.message}</p>
<p>Batch ${i + 1} of ${totalBatches} failed to process.</p>
<p>Would you like to retry this batch?</p>
</div>
<div class="rpg-memory-modal-footer">
<button class="rpg-memory-modal-btn rpg-memory-cancel">Skip Batch</button>
<button class="rpg-memory-modal-btn rpg-memory-proceed">Retry</button>
</div>
</div>
`;
document.body.appendChild(retryModal);
retryModal.querySelector('.rpg-memory-cancel').addEventListener('click', () => {
document.body.removeChild(retryModal);
resolve(false);
});
retryModal.querySelector('.rpg-memory-proceed').addEventListener('click', () => {
document.body.removeChild(retryModal);
resolve(true);
});
});
if (retry) {
// Retry the same batch
i--;
continue;
}
// Otherwise skip this batch and continue
}
// Small delay between batches to avoid rate limiting
if (i < totalBatches - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Add the constant header entry at the end
const headerEntry = createConstantHeaderEntry();
allEntries.push(headerEntry); // Add to end of array
// Save all entries at once
if (allEntries.length > 0) {
debugLog(`[Memory Recollection] Saving ${allEntries.length} total entries (including header) to lorebook...`);
await saveWorldInfoEntries(lorebookUid, allEntries);
// Trigger world info refresh by simulating the WI button click to reload the list
// This ensures the newly created lorebook appears in the dropdown
const wiButton = document.querySelector('#WIDrawerIcon');
if (wiButton) {
// Close and reopen to force refresh
wiButton.click();
await new Promise(resolve => setTimeout(resolve, 100));
wiButton.click();
debugLog('[Memory Recollection] Triggered WI panel refresh');
}
// Also emit the update event
eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
}
debugLog('[Memory Recollection] Process complete');
// Open the World Info editor with the Memory Recollection lorebook
try {
await openWorldInfoEditor(lorebookUid);
debugLog('[Memory Recollection] Opened World Info editor with Memory Recollection lorebook');
} catch (err) {
debugLog('[Memory Recollection] Could not open World Info editor:', err);
}
onComplete(allEntries.length);
} catch (error) {
debugLog('[Memory Recollection] Error:', error);
onError(error);
}
}
/**
* Show memory recollection confirmation modal
*/
export function showMemoryRecollectionModal() {
const modal = document.createElement('div');
modal.className = 'rpg-memory-modal-overlay';
modal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3>⚠️ Memory Recollection</h3>
</div>
<div class="rpg-memory-modal-body">
<p><strong>Warning!</strong> This process will trigger multiple generation requests and will take time.</p>
<p>Ensure your currently selected model is the one you want to use for this task.</p>
<p class="rpg-memory-modal-info">
Messages per batch: <strong>${extensionSettings.memoryMessagesToProcess || 16}</strong>
<br>
<span class="rpg-memory-modal-hint">(You can change this in the extension settings)</span>
</p>
</div>
<div class="rpg-memory-modal-footer">
<button class="rpg-memory-modal-btn rpg-memory-cancel">Cancel</button>
<button class="rpg-memory-modal-btn rpg-memory-proceed">Proceed</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Event listeners
modal.querySelector('.rpg-memory-cancel').addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.querySelector('.rpg-memory-proceed').addEventListener('click', () => {
document.body.removeChild(modal);
showMemoryProgressModal();
});
// Click outside to close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
/**
* Show progress modal during memory recollection
*/
function showMemoryProgressModal() {
const modal = document.createElement('div');
modal.className = 'rpg-memory-modal-overlay';
modal.innerHTML = `
<div class="rpg-memory-modal">
<div class="rpg-memory-modal-header">
<h3>🧠 Processing Memories...</h3>
</div>
<div class="rpg-memory-modal-body">
<p class="rpg-memory-progress-text">Processing batch <span class="rpg-memory-current">0</span> of <span class="rpg-memory-total">0</span></p>
<div class="rpg-memory-progress-bar">
<div class="rpg-memory-progress-fill"></div>
</div>
<p class="rpg-memory-status">Initializing...</p>
</div>
</div>
`;
document.body.appendChild(modal);
const currentSpan = modal.querySelector('.rpg-memory-current');
const totalSpan = modal.querySelector('.rpg-memory-total');
const progressFill = modal.querySelector('.rpg-memory-progress-fill');
const statusText = modal.querySelector('.rpg-memory-status');
// Start the process
startMemoryRecollection(
(current, total) => {
currentSpan.textContent = current;
totalSpan.textContent = total;
const percentage = (current / total) * 100;
progressFill.style.width = `${percentage}%`;
statusText.textContent = `Processing memories from batch ${current}...`;
},
(entriesCreated) => {
statusText.innerHTML = `
<strong>✅ Complete!</strong> Created ${entriesCreated} memory entries.<br>
<small>The "Memory Recollection" lorebook has been created.</small><br>
<strong style="color: #ffa500; margin-top: 10px; display: block;">⚠️ Please refresh SillyTavern to see the lorebook in the World Info dropdown.</strong>
`;
progressFill.style.width = '100%';
// Add close button
const closeBtn = document.createElement('button');
closeBtn.className = 'rpg-memory-modal-btn rpg-memory-close';
closeBtn.textContent = 'Close';
closeBtn.style.marginTop = '15px';
closeBtn.addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.querySelector('.rpg-memory-modal-body').appendChild(closeBtn);
},
(error) => {
statusText.textContent = `Error: ${error.message}`;
statusText.style.color = '#e94560';
// Close after 5 seconds
setTimeout(() => {
document.body.removeChild(modal);
}, 5000);
}
);
}
/**
* Setup the memory recollection button in World Info section
*/
export function setupMemoryRecollectionButton() {
console.log('[Memory Recollection] Setting up button via event listener');
// Use SillyTavern's built-in event to know when WI is ready
// This fires after the worldInfoSettings are loaded
eventSource.on('worldInfoSettings', () => {
console.log('[Memory Recollection] worldInfoSettings event fired');
setTimeout(updateButton, 100);
});
// Also try on app ready
eventSource.on('app_ready', () => {
console.log('[Memory Recollection] app_ready event fired');
setTimeout(updateButton, 500);
});
// Try immediately as well
setTimeout(updateButton, 2000);
function updateButton() {
const existingButton = document.querySelector('.rpg-memory-recollection-btn');
// If extension is disabled, remove button if it exists
if (!extensionSettings.enabled) {
if (existingButton) {
console.log('[Memory Recollection] Extension disabled, removing button');
existingButton.remove();
}
return;
}
// Extension is enabled, add button if it doesn't exist
addButton();
}
function addButton() {
// Check if button already exists
if (document.querySelector('.rpg-memory-recollection-btn')) {
console.log('[Memory Recollection] Button already exists');
return;
}
console.log('[Memory Recollection] Attempting to add button...');
// World Info button bar is inside the world editor
// Look for the specific button container
const selectors = [
'#world_editor_buttons',
'#world_popup .world_button_bar',
'#WorldInfo .world_button_bar',
'.world_button_bar',
'#world_popup .justifyLeft',
'#WorldInfo .justifyLeft',
'#world_popup',
'#WorldInfo'
];
let container = null;
for (const selector of selectors) {
container = document.querySelector(selector);
if (container) {
console.log(`[Memory Recollection] Found container with selector: ${selector}`, container);
break;
}
}
if (!container) {
console.log('[Memory Recollection] No suitable container found yet');
return;
}
// Create the button
const button = document.createElement('button');
button.id = 'rpg-memory-recollection-button';
button.className = 'rpg-memory-recollection-btn menu_button';
button.innerHTML = '<i class="fa-solid fa-brain"></i> Memory Recollection';
button.title = 'Generate memory recollection entries from chat history';
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showMemoryRecollectionModal();
});
// Insert the button - prepend to put it first
if (container.classList.contains('world_button_bar') || container.classList.contains('justifyLeft')) {
container.insertBefore(button, container.firstChild);
} else {
// Find or create a button container
let buttonContainer = container.querySelector('.world_button_bar') ||
container.querySelector('.justifyLeft');
if (!buttonContainer) {
buttonContainer = document.createElement('div');
buttonContainer.className = 'world_button_bar justifyLeft';
container.insertBefore(buttonContainer, container.firstChild);
}
buttonContainer.insertBefore(button, buttonContainer.firstChild);
}
console.log('[Memory Recollection] ✅ Button added successfully!');
}
}
/**
* Update button visibility based on extension enabled state
* Call this when the extension is toggled on/off
*/
export function updateMemoryRecollectionButton() {
const existingButton = document.querySelector('.rpg-memory-recollection-btn');
if (!extensionSettings.enabled) {
// Extension disabled - remove button if it exists
if (existingButton) {
console.log('[Memory Recollection] Extension disabled, removing button');
existingButton.remove();
}
} else {
// Extension enabled - ensure button exists
if (!existingButton) {
console.log('[Memory Recollection] Extension enabled, adding button');
setTimeout(() => {
setupMemoryRecollectionButton();
}, 100);
}
}
}
+13 -6
View File
@@ -5,7 +5,7 @@
import { togglePlotButtons } from '../ui/layout.js';
import { extensionSettings, setIsPlotProgression } from '../../core/state.js';
import { DEFAULT_HTML_PROMPT } from '../generation/promptBuilder.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT } from '../generation/promptBuilder.js';
import { Generate } from '../../../../../../../script.js';
/**
@@ -34,8 +34,8 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
font-size: 13px;
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button">
<i class="fa-solid fa-dice"></i> <span class="rpg-btn-text">Randomized Plot</span>
" tabindex="0" role="button" title="Generate a random plot twist or event">
<i class="fa-solid fa-dice"></i>&nbsp;<span class="rpg-btn-text">Randomized Plot</span>
</button>
<button id="rpg-plot-natural" class="menu_button interactable" style="
background-color: #4a90e2;
@@ -46,8 +46,8 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
font-size: 13px;
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button">
<i class="fa-solid fa-forward"></i> <span class="rpg-btn-text">Natural Plot</span>
" tabindex="0" role="button" title="Continue the story naturally without twists">
<i class="fa-solid fa-forward"></i>&nbsp;<span class="rpg-btn-text">Natural Plot</span>
</button>
<button id="rpg-encounter-button" class="menu_button interactable" style="
background-color: #cc3333;
@@ -59,7 +59,7 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button" title="Enter combat encounter">
<i class="fa-solid fa-fire"></i> <span class="rpg-btn-text">Enter Encounter</span>
<i class="fa-solid fa-fire"></i>&nbsp;<span class="rpg-btn-text">Enter Encounter</span>
</button>
</span>
`;
@@ -114,6 +114,13 @@ export async function sendPlotProgression(type) {
prompt += '\n\n' + htmlPromptText;
}
// Add Dialogue Coloring prompt if enabled
if (extensionSettings.enableDialogueColoring) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
prompt += '\n\n' + dialogueColoringPromptText;
}
// Set flag to indicate we're doing plot progression
// This will be used by onMessageReceived to clear the prompt after generation completes
setIsPlotProgression(true);