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
+23 -50
View File
@@ -7,50 +7,30 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
## 🆕 What's New ## 🆕 What's New
### v2.1.3 ### v3.0.0
- **Improved Thought Bubble Positioning**: Thought bubbles now align with the top of the character's avatar/name
- **Better Visibility**: Fixed issue where thought bubbles would extend above the avatar when scrolling is limited
- **Horizontal Thought Circles**: Thought circles now display horizontally for a cleaner visual flow
- **Responsive Positioning**: Thought bubbles dynamically adjust to screen width changes and stay visible at all resolutions
- **Smart Viewport Detection**: Bubbles automatically reposition to avoid being cut off at narrow window widths
### v2.1.2 **What's new?**
- Added optional toggle for Relationship Status Fields in Edit Trackers - Switched to the JSON format for the trackers.
- Relationship fields and emoji badges can now be disabled/enabled like other trackers - You can now lock/unlock trackers that you don't want the model to change between generations.
- Maintains backward compatibility with existing configurations - Removed features that were half-baked or didn't work.
- Organized Settings and Edit Trackers windows.
- All features of the extension are now accessible from the main panel view.
- Added Colored Dialogues option that makes the model color dialogue lines differently depending on the speaker.
- Introduced Dynamic Weather Effects that add visual effects to your SillyTavern window depending on the current weather from the trackers.
- All prompts used for the extension's features are now editable.
- Made the user's level optional in the Edit Trackers.
### v2.1.1 **Bug Fixes:**
- Fixed a bug in together generation mode that didn't detect swipes correctly - Fixed tracker logic in Together generation mode.
- Fixed combat encounter prompt to consider party members better - Fixed various UI bugs (too many to count).
- Upgraded mobile view.
- Spotify Music widget is more visible now, plus it works in the mobile view.
- Auto-update after messages option is now available for External API generation mode.
- Fixed the display of the thoughts window and its mobile display.
- Fixed smaller bugs.
### v2.1 - Dynamic Weather Effects **Special thanks to all the other contributors for this project:**
- Real-time weather visualization based on Info Box weather field Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, and Amauragis!
- **Snow**: Falling snowflakes with varied speeds and sizes
- **Rain**: Realistic raindrops animation
- **Mist/Fog**: Floating fog layers
- **Sunshine**: Sun rays streaming across the screen
- **Storm**: Combined rain + lightning flashes for thunderstorms
- **Wind**: Horizontal wind streaks for breezy conditions
- **Blizzard**: Combined snow + wind effects
- Weather effects positioned behind chat interface for atmospheric background
- Automatically enabled for new users, manual toggle available in panel
- Fully optimized for mobile devices
### Clothing Inventory System
- New dedicated **Clothing** tab in inventory system
- Separate tracking for clothing and armor items
- AI now handles clothing items independently from general inventory
- Full list/grid view support with add/remove/edit functionality
- Automatic migration for existing users
### Bug Fixes & Improvements
- Fixed tab visibility issues (disabled tabs now properly hide/show)
- Fixed theme-aware borders (removed hardcoded blue colors)
- Fixed double scrollbar in Edit Trackers window
- Fixed scroll position jumping when editing Present Characters
- Fixed dynamic weather toggle visibility behavior
- Added settings migration system for smooth feature updates
- Improved inventory schema to v2.1 with automatic migration
## 📥 Installation ## 📥 Installation
@@ -104,8 +84,6 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
### To-Do ### To-Do
1. Allow users to use a different model for the separate trackers generation 1. Allow users to use a different model for the separate trackers generation
2. ~~Make all trackers and fields customizable~~ ✅ Done!
3. ~~Kill myself~~
## ⚙️ Settings ## ⚙️ Settings
@@ -304,13 +282,8 @@ If you enjoy this extension, consider supporting development:
## 🙏 Credits ## 🙏 Credits
- Extension Development: Marinara with assistance from GitHub Copilot **Contributors:**
- Immersive HTML concept: Credit to u/melted_walrus SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, and Amauragis
- Info Box prompt inspiration: MidnightSleeper
- Stats Tracker concept: Community feedback
- Special thanks to Quack for helping me with the CSS
- Massive kudos to Paperboy for making the mobile version work, fixing bugs, and adding the inventory system
- Thanks to IDeathByte for solving some CSS scaling issues
## 🚀 Planned Features ## 🚀 Planned Features
-264
View File
@@ -1,264 +0,0 @@
{
"chat_completion_source": "custom",
"openai_model": "gpt-4o",
"claude_model": "claude-3-sonnet-20240229",
"openrouter_model": "OR_Website",
"openrouter_use_fallback": false,
"openrouter_group_models": false,
"openrouter_sort_models": "alphabetically",
"openrouter_providers": [],
"openrouter_allow_fallbacks": true,
"openrouter_middleout": "on",
"ai21_model": "jamba-large",
"mistralai_model": "mistral-large-latest",
"cohere_model": "command-r-plus",
"perplexity_model": "llama-3-70b-instruct",
"groq_model": "llama3-70b-8192",
"xai_model": "grok-4-0709",
"pollinations_model": "openai",
"aimlapi_model": "gpt-4o-mini-2024-07-18",
"electronhub_model": "gpt-4o-mini",
"electronhub_sort_models": "alphabetically",
"electronhub_group_models": false,
"moonshot_model": "kimi-latest",
"fireworks_model": "accounts/fireworks/models/kimi-k2-instruct",
"cometapi_model": "gpt-4o",
"custom_model": "",
"custom_prompt_post_processing": "semi",
"google_model": "gemini-pro",
"vertexai_model": "gemini-2.5-pro-exp-03-25",
"azure_api_version": "2024-02-15-preview",
"azure_openai_model": "",
"temperature": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"top_p": 1,
"top_k": 0,
"top_a": 1,
"min_p": 0,
"repetition_penalty": 1,
"openai_max_context": 16384,
"openai_max_tokens": 8192,
"wrap_in_quotes": false,
"names_behavior": -1,
"send_if_empty": "",
"impersonation_prompt": "",
"new_chat_prompt": "",
"new_group_chat_prompt": "",
"new_example_chat_prompt": "",
"continue_nudge_prompt": "",
"bias_preset_selected": "Default (none)",
"max_context_unlocked": false,
"wi_format": "",
"scenario_format": "",
"personality_format": "",
"group_nudge_prompt": "",
"stream_openai": false,
"prompts": [
{
"name": "Main Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "main",
"injection_position": 0,
"injection_depth": 4,
"forbid_overrides": false
},
{
"name": "NSFW Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
},
{
"name": "Jailbreak Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoBefore",
"name": "World Info (before)",
"system_prompt": true,
"marker": true
},
{
"identifier": "enhanceDefinitions",
"role": "system",
"name": "Enhance Definitions",
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
"system_prompt": true,
"marker": false
},
{
"identifier": "charDescription",
"name": "Char Description",
"system_prompt": true,
"marker": true
},
{
"identifier": "charPersonality",
"name": "Char Personality",
"system_prompt": true,
"marker": true
},
{
"identifier": "scenario",
"name": "Scenario",
"system_prompt": true,
"marker": true
},
{
"identifier": "personaDescription",
"name": "Persona Description",
"system_prompt": true,
"marker": true
}
],
"prompt_order": [
{
"character_id": 100000,
"order": [
{
"identifier": "main",
"enabled": true
},
{
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "charDescription",
"enabled": true
},
{
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "scenario",
"enabled": true
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "jailbreak",
"enabled": true
}
]
},
{
"character_id": 100001,
"order": [
{
"identifier": "main",
"enabled": false
},
{
"identifier": "worldInfoBefore",
"enabled": false
},
{
"identifier": "personaDescription",
"enabled": false
},
{
"identifier": "charDescription",
"enabled": false
},
{
"identifier": "charPersonality",
"enabled": false
},
{
"identifier": "scenario",
"enabled": false
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": false
},
{
"identifier": "worldInfoAfter",
"enabled": false
},
{
"identifier": "dialogueExamples",
"enabled": false
},
{
"identifier": "chatHistory",
"enabled": false
},
{
"identifier": "jailbreak",
"enabled": false
}
]
}
],
"show_external_models": false,
"assistant_prefill": "",
"assistant_impersonation": "",
"claude_use_sysprompt": true,
"use_makersuite_sysprompt": true,
"vertexai_auth_mode": "full",
"squash_system_messages": true,
"image_inlining": false,
"inline_image_quality": "auto",
"video_inlining": false,
"bypass_status_check": false,
"continue_prefill": false,
"continue_postfix": "",
"function_calling": false,
"show_thoughts": false,
"reasoning_effort": "auto",
"enable_web_search": false,
"request_images": false,
"seed": -1,
"n": 1,
"extensions": {}
}
+126 -145
View File
@@ -6,6 +6,7 @@ import { power_user } from '../../../power-user.js';
// Core modules // Core modules
import { extensionName, extensionFolderPath } from './src/core/config.js'; import { extensionName, extensionFolderPath } from './src/core/config.js';
import { i18n } from './src/core/i18n.js'; import { i18n } from './src/core/i18n.js';
import { migrateToV3JSON } from './src/utils/jsonMigration.js';
import { import {
extensionSettings, extensionSettings,
lastGeneratedData, lastGeneratedData,
@@ -92,7 +93,8 @@ import {
setupSettingsPopup, setupSettingsPopup,
updateDiceDisplay, updateDiceDisplay,
addDiceQuickReply, addDiceQuickReply,
getSettingsModal getSettingsModal,
showWelcomeModalIfNeeded
} from './src/systems/ui/modals.js'; } from './src/systems/ui/modals.js';
import { import {
initTrackerEditor initTrackerEditor
@@ -133,9 +135,8 @@ import {
// Feature modules // Feature modules
import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js'; import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js';
import { setupClassicStatsButtons } from './src/systems/features/classicStats.js'; import { setupClassicStatsButtons } from './src/systems/features/classicStats.js';
import { ensureHtmlCleaningRegex, detectConflictingRegexScripts } from './src/systems/features/htmlCleaning.js'; import { ensureHtmlCleaningRegex, detectConflictingRegexScripts, ensureTrackerCleaningRegex } from './src/systems/features/htmlCleaning.js';
import { setupMemoryRecollectionButton, updateMemoryRecollectionButton } from './src/systems/features/memoryRecollection.js'; import { ensureJsonCleaningRegex, removeJsonCleaningRegex } from './src/systems/features/jsonCleaning.js';
import { initLorebookLimiter } from './src/systems/features/lorebookLimiter.js';
import { parseAndStoreSpotifyUrl } from './src/systems/features/musicPlayer.js'; import { parseAndStoreSpotifyUrl } from './src/systems/features/musicPlayer.js';
import { DEFAULT_HTML_PROMPT } from './src/systems/generation/promptBuilder.js'; import { DEFAULT_HTML_PROMPT } from './src/systems/generation/promptBuilder.js';
import { openEncounterModal } from './src/systems/ui/encounterUI.js'; import { openEncounterModal } from './src/systems/ui/encounterUI.js';
@@ -212,12 +213,13 @@ async function addExtensionSettings() {
updateChatThoughts(); // Remove thought bubbles updateChatThoughts(); // Remove thought bubbles
cleanupCheckpointUI(); // Remove checkpoint buttons and indicators cleanupCheckpointUI(); // Remove checkpoint buttons and indicators
// Disable dynamic weather effects
toggleDynamicWeather(false);
// Remove panel and toggle buttons // Remove panel and toggle buttons
$('#rpg-companion-panel').remove(); $('#rpg-companion-panel').remove();
$('#rpg-mobile-toggle').remove(); $('#rpg-mobile-toggle').remove();
$('#rpg-collapse-toggle').remove(); $('#rpg-collapse-toggle').remove();
$('#rpg-debug-toggle').remove();
$('#rpg-debug-panel').remove();
$('#rpg-plot-buttons').remove(); // Remove plot buttons $('#rpg-plot-buttons').remove(); // Remove plot buttons
} else if (extensionSettings.enabled && !wasEnabled) { } else if (extensionSettings.enabled && !wasEnabled) {
// Enabling extension - initialize UI // Enabling extension - initialize UI
@@ -227,9 +229,6 @@ async function addExtensionSettings() {
injectCheckpointButton(); // Re-add checkpoint buttons injectCheckpointButton(); // Re-add checkpoint buttons
updateAllCheckpointIndicators(); // Update button states updateAllCheckpointIndicators(); // Update button states
} }
// Update Memory Recollection button visibility
updateMemoryRecollectionButton();
}); });
// Set up language selector // Set up language selector
@@ -265,8 +264,9 @@ async function initUI() {
$('body').append(templateHtml); $('body').append(templateHtml);
// Add mobile toggle button (FAB - Floating Action Button) // Add mobile toggle button (FAB - Floating Action Button)
const theme = extensionSettings.theme || 'default';
const mobileToggleHtml = ` const mobileToggleHtml = `
<button id="rpg-mobile-toggle" class="rpg-mobile-toggle" title="Toggle RPG Panel"> <button id="rpg-mobile-toggle" class="rpg-mobile-toggle" data-theme="${theme}" title="Toggle RPG Panel">
<i class="fa-solid fa-dice-d20"></i> <i class="fa-solid fa-dice-d20"></i>
</button> </button>
`; `;
@@ -309,21 +309,21 @@ async function initUI() {
saveSettings(); saveSettings();
}); });
$('#rpg-memory-messages').on('change', function() { $('#rpg-generation-mode').on('change', async function() {
const value = $(this).val();
extensionSettings.memoryMessagesToProcess = parseInt(String(value));
saveSettings();
});
$('#rpg-generation-mode').on('change', function() {
extensionSettings.generationMode = String($(this).val()); extensionSettings.generationMode = String($(this).val());
saveSettings(); saveSettings();
updateGenerationModeUI(); updateGenerationModeUI();
});
$('#rpg-use-separate-preset').on('change', function() { // Add or remove JSON cleaning regex based on mode
extensionSettings.useSeparatePreset = $(this).prop('checked'); try {
saveSettings(); if (extensionSettings.generationMode === 'together') {
await ensureJsonCleaningRegex(st_extension_settings, saveSettingsDebounced);
} else {
removeJsonCleaningRegex(st_extension_settings, saveSettingsDebounced);
}
} catch (error) {
console.error('[RPG Companion] JSON cleaning regex update failed:', error);
}
}); });
$('#rpg-toggle-user-stats').on('change', function() { $('#rpg-toggle-user-stats').on('change', function() {
@@ -344,11 +344,6 @@ async function initUI() {
updateSectionVisibility(); updateSectionVisibility();
}); });
$('#rpg-toggle-narrator-mode').on('change', function() {
extensionSettings.narratorMode = $(this).prop('checked');
saveSettings();
});
$('#rpg-toggle-inventory').on('change', function() { $('#rpg-toggle-inventory').on('change', function() {
extensionSettings.showInventory = $(this).prop('checked'); extensionSettings.showInventory = $(this).prop('checked');
saveSettings(); saveSettings();
@@ -361,6 +356,17 @@ async function initUI() {
updateSectionVisibility(); updateSectionVisibility();
}); });
$('#rpg-toggle-lock-icons').on('change', function() {
extensionSettings.showLockIcons = $(this).prop('checked');
saveSettings();
// Re-render all sections to show/hide lock icons
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
});
$('#rpg-toggle-thoughts-in-chat').on('change', function() { $('#rpg-toggle-thoughts-in-chat').on('change', function() {
extensionSettings.showThoughtsInChat = $(this).prop('checked'); extensionSettings.showThoughtsInChat = $(this).prop('checked');
// console.log('[RPG Companion] Toggle showThoughtsInChat changed to:', extensionSettings.showThoughtsInChat); // console.log('[RPG Companion] Toggle showThoughtsInChat changed to:', extensionSettings.showThoughtsInChat);
@@ -368,23 +374,18 @@ async function initUI() {
updateChatThoughts(); updateChatThoughts();
}); });
$('#rpg-toggle-always-show-bubble').on('change', function() {
extensionSettings.alwaysShowThoughtBubble = $(this).prop('checked');
saveSettings();
// Force immediate save to ensure setting is persisted before any other code runs
const context = getContext();
const extension_settings = context.extension_settings || context.extensionSettings;
extension_settings[extensionName] = extensionSettings;
// Re-render thoughts to apply the setting
updateChatThoughts();
});
$('#rpg-toggle-html-prompt').on('change', function() { $('#rpg-toggle-html-prompt').on('change', function() {
extensionSettings.enableHtmlPrompt = $(this).prop('checked'); extensionSettings.enableHtmlPrompt = $(this).prop('checked');
// console.log('[RPG Companion] Toggle enableHtmlPrompt changed to:', extensionSettings.enableHtmlPrompt); // console.log('[RPG Companion] Toggle enableHtmlPrompt changed to:', extensionSettings.enableHtmlPrompt);
saveSettings(); saveSettings();
}); });
$('#rpg-toggle-dialogue-coloring').on('change', function() {
extensionSettings.enableDialogueColoring = $(this).prop('checked');
// console.log('[RPG Companion] Toggle enableDialogueColoring changed to:', extensionSettings.enableDialogueColoring);
saveSettings();
});
$('#rpg-toggle-spotify-music').on('change', function() { $('#rpg-toggle-spotify-music').on('change', function() {
extensionSettings.enableSpotifyMusic = $(this).prop('checked'); extensionSettings.enableSpotifyMusic = $(this).prop('checked');
saveSettings(); saveSettings();
@@ -392,11 +393,7 @@ async function initUI() {
renderMusicPlayer($musicPlayerContainer[0]); renderMusicPlayer($musicPlayerContainer[0]);
}); });
$('#rpg-toggle-snowflakes').on('change', function() {
extensionSettings.enableSnowflakes = $(this).prop('checked');
saveSettings();
toggleSnowflakes(extensionSettings.enableSnowflakes);
});
$('#rpg-toggle-dynamic-weather').on('change', function() { $('#rpg-toggle-dynamic-weather').on('change', function() {
extensionSettings.enableDynamicWeather = $(this).prop('checked'); extensionSettings.enableDynamicWeather = $(this).prop('checked');
@@ -404,6 +401,11 @@ async function initUI() {
toggleDynamicWeather(extensionSettings.enableDynamicWeather); toggleDynamicWeather(extensionSettings.enableDynamicWeather);
}); });
$('#rpg-toggle-narrator').on('change', function() {
extensionSettings.narratorMode = $(this).prop('checked');
saveSettings();
});
$('#rpg-dismiss-promo').on('click', function() { $('#rpg-dismiss-promo').on('click', function() {
extensionSettings.dismissedHolidayPromo = true; extensionSettings.dismissedHolidayPromo = true;
saveSettings(); saveSettings();
@@ -420,9 +422,14 @@ async function initUI() {
saveSettings(); saveSettings();
}); });
$('#rpg-toggle-plot-buttons').on('change', function() { $('#rpg-toggle-randomized-plot').on('change', function() {
extensionSettings.enablePlotButtons = $(this).prop('checked'); extensionSettings.enableRandomizedPlot = $(this).prop('checked');
// console.log('[RPG Companion] Toggle enablePlotButtons changed to:', extensionSettings.enablePlotButtons); saveSettings();
togglePlotButtons();
});
$('#rpg-toggle-natural-plot').on('change', function() {
extensionSettings.enableNaturalPlot = $(this).prop('checked');
saveSettings(); saveSettings();
togglePlotButtons(); togglePlotButtons();
}); });
@@ -543,12 +550,6 @@ async function initUI() {
saveSettings(); saveSettings();
}); });
$('#rpg-toggle-animations').on('change', function() {
extensionSettings.enableAnimations = $(this).prop('checked');
saveSettings();
toggleAnimations();
});
// Feature toggle visibility controls // Feature toggle visibility controls
$('#rpg-toggle-show-html-toggle').on('change', function() { $('#rpg-toggle-show-html-toggle').on('change', function() {
extensionSettings.showHtmlToggle = $(this).prop('checked'); extensionSettings.showHtmlToggle = $(this).prop('checked');
@@ -556,14 +557,14 @@ async function initUI() {
updateFeatureTogglesVisibility(); updateFeatureTogglesVisibility();
}); });
$('#rpg-toggle-show-spotify-toggle').on('change', function() { $('#rpg-toggle-show-dialogue-coloring-toggle').on('change', function() {
extensionSettings.showSpotifyToggle = $(this).prop('checked'); extensionSettings.showDialogueColoringToggle = $(this).prop('checked');
saveSettings(); saveSettings();
updateFeatureTogglesVisibility(); updateFeatureTogglesVisibility();
}); });
$('#rpg-toggle-show-snowflakes-toggle').on('change', function() { $('#rpg-toggle-show-spotify-toggle').on('change', function() {
extensionSettings.showSnowflakesToggle = $(this).prop('checked'); extensionSettings.showSpotifyToggle = $(this).prop('checked');
saveSettings(); saveSettings();
updateFeatureTogglesVisibility(); updateFeatureTogglesVisibility();
}); });
@@ -580,14 +581,30 @@ async function initUI() {
updateFeatureTogglesVisibility(); updateFeatureTogglesVisibility();
}); });
$('#rpg-toggle-show-snowflakes-toggle').on('change', function() { $('#rpg-toggle-show-narrator-mode').on('change', function() {
extensionSettings.showSnowflakesToggle = $(this).prop('checked'); extensionSettings.showNarratorMode = $(this).prop('checked');
// Also disable the feature when hiding the toggle
if (!extensionSettings.showNarratorMode) {
extensionSettings.narratorMode = false;
$('#rpg-toggle-narrator').prop('checked', false);
}
saveSettings(); saveSettings();
updateFeatureTogglesVisibility(); updateFeatureTogglesVisibility();
}); });
// Auto avatar generation settings $('#rpg-toggle-show-auto-avatars').on('change', function() {
$('#rpg-toggle-auto-avatars').on('change', function() { extensionSettings.showAutoAvatars = $(this).prop('checked');
// Also disable the feature when hiding the toggle
if (!extensionSettings.showAutoAvatars) {
extensionSettings.autoGenerateAvatars = false;
$('#rpg-toggle-auto-avatars-panel').prop('checked', false);
}
saveSettings();
updateFeatureTogglesVisibility();
});
// Auto avatar generation panel toggle
$('#rpg-toggle-auto-avatars-panel').on('change', function() {
extensionSettings.autoGenerateAvatars = $(this).prop('checked'); extensionSettings.autoGenerateAvatars = $(this).prop('checked');
saveSettings(); saveSettings();
@@ -769,34 +786,35 @@ async function initUI() {
$('#rpg-toggle-auto-update').prop('checked', extensionSettings.autoUpdate); $('#rpg-toggle-auto-update').prop('checked', extensionSettings.autoUpdate);
$('#rpg-position-select').val(extensionSettings.panelPosition); $('#rpg-position-select').val(extensionSettings.panelPosition);
$('#rpg-update-depth').val(extensionSettings.updateDepth); $('#rpg-update-depth').val(extensionSettings.updateDepth);
$('#rpg-memory-messages').val(extensionSettings.memoryMessagesToProcess || 16);
$('#rpg-use-separate-preset').prop('checked', extensionSettings.useSeparatePreset);
$('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats); $('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats);
$('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox); $('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox);
$('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts); $('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts);
$('#rpg-toggle-narrator-mode').prop('checked', extensionSettings.narratorMode);
$('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory); $('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory);
$('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests); $('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
$('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat); $('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat);
$('#rpg-toggle-always-show-bubble').prop('checked', extensionSettings.alwaysShowThoughtBubble);
$('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt); $('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt);
$('#rpg-toggle-dialogue-coloring').prop('checked', extensionSettings.enableDialogueColoring);
$('#rpg-toggle-spotify-music').prop('checked', extensionSettings.enableSpotifyMusic); $('#rpg-toggle-spotify-music').prop('checked', extensionSettings.enableSpotifyMusic);
$('#rpg-toggle-snowflakes').prop('checked', extensionSettings.enableSnowflakes);
$('#rpg-toggle-dynamic-weather').prop('checked', extensionSettings.enableDynamicWeather); $('#rpg-toggle-dynamic-weather').prop('checked', extensionSettings.enableDynamicWeather);
$('#rpg-toggle-narrator').prop('checked', extensionSettings.narratorMode);
// Feature toggle visibility settings // Feature toggle visibility settings
$('#rpg-toggle-show-html-toggle').prop('checked', extensionSettings.showHtmlToggle ?? true); $('#rpg-toggle-show-html-toggle').prop('checked', extensionSettings.showHtmlToggle ?? true);
$('#rpg-toggle-show-dialogue-coloring-toggle').prop('checked', extensionSettings.showDialogueColoringToggle ?? true);
$('#rpg-toggle-show-spotify-toggle').prop('checked', extensionSettings.showSpotifyToggle ?? true); $('#rpg-toggle-show-spotify-toggle').prop('checked', extensionSettings.showSpotifyToggle ?? true);
$('#rpg-toggle-show-snowflakes-toggle').prop('checked', extensionSettings.showSnowflakesToggle ?? true);
$('#rpg-toggle-show-dynamic-weather-toggle').prop('checked', extensionSettings.showDynamicWeatherToggle ?? true); $('#rpg-toggle-show-dynamic-weather-toggle').prop('checked', extensionSettings.showDynamicWeatherToggle ?? true);
$('#rpg-toggle-show-snowflakes-toggle').prop('checked', extensionSettings.showSnowflakesToggle ?? true); $('#rpg-toggle-show-narrator-mode').prop('checked', extensionSettings.showNarratorMode ?? true);
$('#rpg-toggle-show-auto-avatars').prop('checked', extensionSettings.showAutoAvatars ?? true);
// Hide holiday promo if previously dismissed // Hide holiday promo if previously dismissed
if (extensionSettings.dismissedHolidayPromo) { if (extensionSettings.dismissedHolidayPromo) {
$('#rpg-holiday-promo').hide(); $('#rpg-holiday-promo').hide();
} }
$('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); $('#rpg-toggle-randomized-plot').prop('checked', extensionSettings.enableRandomizedPlot ?? true);
$('#rpg-toggle-natural-plot').prop('checked', extensionSettings.enableNaturalPlot ?? true);
$('#rpg-toggle-encounters').prop('checked', extensionSettings.encounterSettings?.enabled ?? true); $('#rpg-toggle-encounters').prop('checked', extensionSettings.encounterSettings?.enabled ?? true);
$('#rpg-encounter-history-depth').val(extensionSettings.encounterSettings?.historyDepth ?? 8); $('#rpg-encounter-history-depth').val(extensionSettings.encounterSettings?.historyDepth ?? 8);
$('#rpg-toggle-autosave-logs').prop('checked', extensionSettings.encounterSettings?.autoSaveLogs ?? true); $('#rpg-toggle-autosave-logs').prop('checked', extensionSettings.encounterSettings?.autoSaveLogs ?? true);
@@ -813,10 +831,8 @@ async function initUI() {
$('#rpg-summary-narration').val(extensionSettings.encounterSettings?.summaryNarrative?.narration ?? 'omniscient'); $('#rpg-summary-narration').val(extensionSettings.encounterSettings?.summaryNarrative?.narration ?? 'omniscient');
$('#rpg-summary-pov').val(extensionSettings.encounterSettings?.summaryNarrative?.pov ?? 'narrator'); $('#rpg-summary-pov').val(extensionSettings.encounterSettings?.summaryNarrative?.pov ?? 'narrator');
$('#rpg-toggle-animations').prop('checked', extensionSettings.enableAnimations); // Initialize avatar options (panel toggle)
$('#rpg-toggle-auto-avatars-panel').prop('checked', extensionSettings.autoGenerateAvatars || false);
// Initialize avatar options
$('#rpg-toggle-auto-avatars').prop('checked', extensionSettings.autoGenerateAvatars || false);
$('#rpg-toggle-dice-display').prop('checked', extensionSettings.showDiceDisplay); $('#rpg-toggle-dice-display').prop('checked', extensionSettings.showDiceDisplay);
$('#rpg-stat-bar-color-low').val(extensionSettings.statBarColorLow); $('#rpg-stat-bar-color-low').val(extensionSettings.statBarColorLow);
@@ -889,12 +905,6 @@ async function initUI() {
initChapterCheckpointUI(); initChapterCheckpointUI();
injectCheckpointButton(); injectCheckpointButton();
// Setup Memory Recollection button in World Info
setupMemoryRecollectionButton();
// Initialize Lorebook Limiter
initLorebookLimiter();
// Expose weather effect functions globally for cross-module access // Expose weather effect functions globally for cross-module access
if (!window.RPGCompanion) { if (!window.RPGCompanion) {
window.RPGCompanion = {}; window.RPGCompanion = {};
@@ -914,70 +924,6 @@ async function initUI() {
// (commitTrackerData, onMessageSent, onMessageReceived, onCharacterChanged, // (commitTrackerData, onMessageSent, onMessageReceived, onCharacterChanged,
// onMessageSwiped, updatePersonaAvatar, clearExtensionPrompts) // onMessageSwiped, updatePersonaAvatar, clearExtensionPrompts)
/**
* Ensures the "RPG Companion Trackers" preset exists in the user's OpenAI Settings.
* Imports the preset file from the extension folder if it doesn't exist.
*/
async function ensureTrackerPresetExists() {
try {
const presetName = 'RPG Companion Trackers';
// Check if preset already exists by fetching settings
const checkResponse = await fetch('/api/settings/get', {
method: 'POST',
headers: getRequestHeaders()
});
if (checkResponse.ok) {
const settings = await checkResponse.json();
// openai_setting_names is an array of preset names
if (settings.openai_setting_names && settings.openai_setting_names.includes(presetName)) {
console.log(`[RPG Companion] Preset "${presetName}" already exists`);
return;
}
}
// Preset doesn't exist - import it from extension folder
console.log(`[RPG Companion] Importing preset "${presetName}"...`);
// Load preset from extension folder
const extensionPresetPath = `${extensionFolderPath}/${presetName}.json`;
const presetResponse = await fetch(`/${extensionPresetPath}`);
if (!presetResponse.ok) {
console.warn(`[RPG Companion] Could not load preset template from ${extensionPresetPath}`);
return;
}
const presetData = await presetResponse.json();
// Save preset to user's OpenAI Settings folder using SillyTavern's API
const saveResponse = await fetch('/api/presets/save', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
apiId: 'openai',
name: presetName,
preset: presetData
})
});
if (saveResponse.ok) {
console.log(`[RPG Companion] ✅ Successfully imported preset "${presetName}"`);
toastr.success(
`The "RPG Companion Trackers" preset has been imported to your OpenAI Settings.`,
'RPG Companion',
{ timeOut: 5000 }
);
} else {
console.warn(`[RPG Companion] Failed to save preset: ${saveResponse.statusText}`);
}
} catch (error) {
console.error('[RPG Companion] Error importing tracker preset:', error);
// Non-critical - users can manually import if needed
}
}
/** /**
* Main initialization function. * Main initialization function.
*/ */
@@ -992,6 +938,20 @@ jQuery(async () => {
console.error('[RPG Companion] Settings load failed, continuing with defaults:', error); console.error('[RPG Companion] Settings load failed, continuing with defaults:', error);
} }
// Check if migration to v3 JSON format is needed
try {
if (extensionSettings.settingsVersion < 3) {
console.log('[RPG Companion] Detected v2 format, migrating to v3 JSON...');
await migrateToV3JSON();
updateExtensionSettings({ settingsVersion: 3 });
await saveSettings();
console.log('[RPG Companion] ✅ Migration to v3 complete');
}
} catch (error) {
console.error('[RPG Companion] Migration to v3 failed:', error);
// Non-critical - extension can still work with v2 format
}
// Initialize i18n early for the settings panel // Initialize i18n early for the settings panel
await i18n.init(); await i18n.init();
@@ -1029,12 +989,25 @@ jQuery(async () => {
// Non-critical - continue without it // Non-critical - continue without it
} }
// Import the RPG Companion Trackers preset if needed // Import the tracker cleaning regex (removes old together mode JSON from prompts)
try { try {
await ensureTrackerPresetExists(); await ensureTrackerCleaningRegex(st_extension_settings, saveSettingsDebounced);
} catch (error) { } catch (error) {
console.error('[RPG Companion] Preset import failed:', error); console.error('[RPG Companion] Tracker cleaning regex import failed:', error);
// Non-critical - users can manually import if needed // Non-critical - continue without it
}
// Import the JSON cleaning regex for Together mode if enabled
try {
if (extensionSettings.generationMode === 'together') {
await ensureJsonCleaningRegex(st_extension_settings, saveSettingsDebounced);
} else {
// Remove the regex if switching to separate mode
removeJsonCleaningRegex(st_extension_settings, saveSettingsDebounced);
}
} catch (error) {
console.error('[RPG Companion] JSON cleaning regex setup failed:', error);
// Non-critical - continue without it
} }
// Detect conflicting regex scripts from old manual formatters // Detect conflicting regex scripts from old manual formatters
@@ -1086,6 +1059,14 @@ jQuery(async () => {
// Non-critical - continue without it // Non-critical - continue without it
} }
// Show welcome modal for v3.0 on first launch
try {
showWelcomeModalIfNeeded();
} catch (error) {
console.error('[RPG Companion] Welcome modal failed:', error);
// Non-critical - continue without it
}
console.log('[RPG Companion] ✅ Extension loaded successfully'); console.log('[RPG Companion] ✅ Extension loaded successfully');
} catch (error) { } catch (error) {
console.error('[RPG Companion] ❌ Critical initialization failure:', error); console.error('[RPG Companion] ❌ Critical initialization failure:', error);
+2 -2
View File
@@ -5,7 +5,7 @@
"optional": [], "optional": [],
"js": "index.js", "js": "index.js",
"css": "style.css", "css": "style.css",
"author": "Marysia", "author": "Marinara",
"version": "2.1.3", "version": "3.0.0",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern" "homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
} }
+12 -3
View File
@@ -22,15 +22,24 @@
<div style="margin-top: 10px; display: flex; gap: 10px;"> <div style="margin-top: 10px; display: flex; gap: 10px;">
<a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;"> <a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-brands fa-discord"></i> Discord <i class="fa-brands fa-discord"></i>&nbsp;Discord
</a> </a>
<a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;"> <a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-solid fa-heart"></i> Support <i class="fa-solid fa-heart"></i>&nbsp;Support
</a> </a>
</div> </div>
<div style="margin-top: 15px; text-align: center; opacity: 0.7; font-size: 0.8em; line-height: 1.5;">
<div style="margin-bottom: 5px;">
<i class="fa-solid fa-users"></i> <strong>Contributors:</strong>
</div>
<div style="opacity: 0.8; font-size: 0.9em;">
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, and Amauragis
</div>
</div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;"> <div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
RPG Companion v2.1.2 v3.0.0
</div> </div>
</div> </div>
</div> </div>
+2 -5
View File
@@ -26,14 +26,13 @@ export const defaultSettings = {
autoUpdate: true, autoUpdate: true,
updateDepth: 4, // How many messages to include in the context updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
useSeparatePreset: false, // Use 'RPG Companion Trackers' preset for tracker generation instead of main API model
showUserStats: true, showUserStats: true,
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
showInventory: true, // Show inventory section (v2 system) showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
alwaysShowThoughtBubble: false, // Auto-expand thought bubble without clicking icon
enableHtmlPrompt: false, // Enable immersive HTML prompt injection enableHtmlPrompt: false, // Enable immersive HTML prompt injection
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
@@ -86,7 +85,5 @@ export const defaultSettings = {
cha: 10 cha: 10
}, },
lastDiceRoll: null, // Store last dice roll result lastDiceRoll: null, // Store last dice roll result
collapsedInventoryLocations: [], // Array of collapsed storage location names collapsedInventoryLocations: [] // Array of collapsed storage location names
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection
}; };
+38 -6
View File
@@ -17,6 +17,7 @@ import {
} from './state.js'; } from './state.js';
import { migrateInventory } from '../utils/migration.js'; import { migrateInventory } from '../utils/migration.js';
import { validateStoredInventory, cleanItemString } from '../utils/security.js'; import { validateStoredInventory, cleanItemString } from '../utils/security.js';
import { migrateToV3JSON } from '../utils/jsonMigration.js';
const extensionName = 'third-party/rpg-companion-sillytavern'; const extensionName = 'third-party/rpg-companion-sillytavern';
@@ -91,6 +92,14 @@ export function loadSettings() {
settingsChanged = true; settingsChanged = true;
} }
// Migration to version 3: Convert text trackers to JSON format
if (currentVersion < 3) {
console.log('[RPG Companion] Migrating settings to version 3 (JSON tracker format)');
migrateToV3JSON();
extensionSettings.settingsVersion = 3;
settingsChanged = true;
}
// Save migrated settings // Save migrated settings
if (settingsChanged) { if (settingsChanged) {
saveSettings(); saveSettings();
@@ -152,6 +161,14 @@ export function saveChatData() {
return; return;
} }
console.log('[RPG Companion] 💾 saveChatData called - committedTrackerData:', {
userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
});
console.log('[RPG Companion] 💾 saveChatData RAW committedTrackerData:', committedTrackerData);
console.log('[RPG Companion] 💾 saveChatData RAW lastGeneratedData:', lastGeneratedData);
chat_metadata.rpg_companion = { chat_metadata.rpg_companion = {
userStats: extensionSettings.userStats, userStats: extensionSettings.userStats,
classicStats: extensionSettings.classicStats, classicStats: extensionSettings.classicStats,
@@ -265,14 +282,29 @@ export function loadChatData() {
}; };
} }
// Restore last generated data // Restore committed tracker data first
if (savedData.lastGeneratedData) { if (savedData.committedTrackerData) {
setLastGeneratedData({ ...savedData.lastGeneratedData }); console.log('[RPG Companion] 📥 loadChatData restoring committedTrackerData:', {
userStats: savedData.committedTrackerData.userStats ? `${savedData.committedTrackerData.userStats.substring(0, 50)}...` : 'null',
infoBox: savedData.committedTrackerData.infoBox ? 'exists' : 'null',
characterThoughts: savedData.committedTrackerData.characterThoughts ? 'exists' : 'null'
});
console.log('[RPG Companion] 📥 RAW savedData.committedTrackerData:', savedData.committedTrackerData);
console.log('[RPG Companion] 📥 Type check:', {
userStatsType: typeof savedData.committedTrackerData.userStats,
infoBoxType: typeof savedData.committedTrackerData.infoBox,
characterThoughtsType: typeof savedData.committedTrackerData.characterThoughts
});
setCommittedTrackerData({ ...savedData.committedTrackerData });
} }
// Restore committed tracker data // Restore last generated data (for display)
if (savedData.committedTrackerData) { // Always prefer lastGeneratedData as it contains the most recent generation (including swipes)
setCommittedTrackerData({ ...savedData.committedTrackerData }); if (savedData.lastGeneratedData) {
console.log('[RPG Companion] 📥 loadChatData restoring lastGeneratedData');
setLastGeneratedData({ ...savedData.lastGeneratedData });
} else {
console.log('[RPG Companion] ⚠️ No lastGeneratedData found in save');
} }
// Migrate inventory in chat data if feature flag enabled // Migrate inventory in chat data if feature flag enabled
+86 -23
View File
@@ -10,12 +10,11 @@
* Extension settings - persisted to SillyTavern settings * Extension settings - persisted to SillyTavern settings
*/ */
export let extensionSettings = { export let extensionSettings = {
settingsVersion: 2, // Version number for settings migrations settingsVersion: 3, // Version number for settings migrations (v3 = JSON format)
enabled: true, enabled: true,
autoUpdate: true, autoUpdate: false,
updateDepth: 4, // How many messages to include in the context updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
useSeparatePreset: false, // Use 'RPG Companion Trackers' preset for tracker generation instead of main API model
showUserStats: true, showUserStats: true,
showInfoBox: true, showInfoBox: true,
showCharacterThoughts: true, showCharacterThoughts: true,
@@ -23,19 +22,26 @@ export let extensionSettings = {
showQuests: true, // Show quests section showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat showThoughtsInChat: true, // Show thoughts overlay in chat
narratorMode: false, // Use character card as narrator instead of fixed character references narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
enableHtmlPrompt: false, // Enable immersive HTML prompt injection enableHtmlPrompt: false, // Enable immersive HTML prompt injection
customHtmlPrompt: '', // Custom HTML prompt text (empty = use default) customHtmlPrompt: '', // Custom HTML prompt text (empty = use default)
enableDialogueColoring: false, // Enable dialogue coloring prompt injection
customDialogueColoringPrompt: '', // Custom dialogue coloring prompt text (empty = use default)
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs) enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default) customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
enableSnowflakes: false, // Enable festive snowflakes effect
enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default) enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
dismissedHolidayPromo: false, // User dismissed the holiday promotion banner dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
showHtmlToggle: true, // Show Immersive HTML toggle in main panel showHtmlToggle: true, // Show Immersive HTML toggle in main panel
showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
showSpotifyToggle: true, // Show Spotify Music toggle in main panel showSpotifyToggle: true, // Show Spotify Music toggle in main panel
showSnowflakesToggle: true, // Show Snowflakes Effect toggle in main panel
showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel
showNarratorMode: true, // Show Narrator Mode toggle in main panel
showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility) skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
enablePlotButtons: true, // Show plot progression buttons above chat input enableRandomizedPlot: true, // Show randomized plot progression button above chat input
enableNaturalPlot: true, // Show natural plot progression button above chat input
saveTrackerHistory: false, // Save tracker data in chat history for each message saveTrackerHistory: false, // Save tracker data in chat history for each message
panelPosition: 'right', // 'left', 'right', or 'top' panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
@@ -52,22 +58,27 @@ export let extensionSettings = {
top: 'calc(var(--topBarBlockSize) + 60px)', top: 'calc(var(--topBarBlockSize) + 60px)',
right: '12px' right: '12px'
}, // Saved position for mobile FAB button }, // Saved position for mobile FAB button
userStats: { userStats: JSON.stringify({
health: 100, stats: [
satiety: 100, { id: 'health', name: 'Health', value: 100 },
energy: 100, { id: 'satiety', name: 'Satiety', value: 100 },
hygiene: 100, { id: 'energy', name: 'Energy', value: 100 },
arousal: 0, { id: 'hygiene', name: 'Hygiene', value: 100 },
mood: '😐', { id: 'arousal', name: 'Arousal', value: 0 }
conditions: 'None', ],
/** @type {InventoryV2} */ status: {
mood: '😐',
conditions: 'None'
},
inventory: { inventory: {
version: 2, onPerson: [],
onPerson: "None", stored: []
stored: {}, },
assets: "None" quests: {
active: [],
completed: []
} }
}, }, null, 2),
statNames: { statNames: {
health: 'Health', health: 'Health',
satiety: 'Satiety', satiety: 'Satiety',
@@ -88,6 +99,7 @@ export let extensionSettings = {
], ],
// RPG Attributes (customizable D&D-style attributes) // RPG Attributes (customizable D&D-style attributes)
showRPGAttributes: true, showRPGAttributes: true,
showLevel: true, // Show/hide level in UI and prompts
alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls
rpgAttributes: [ rpgAttributes: [
{ id: 'str', name: 'STR', enabled: true }, { id: 'str', name: 'STR', enabled: true },
@@ -170,6 +182,16 @@ export let extensionSettings = {
main: "None", // Current main quest title main: "None", // Current main quest title
optional: [] // Array of optional quest titles optional: [] // Array of optional quest titles
}, },
infoBox: JSON.stringify({
date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) },
weather: { emoji: '☀️', forecast: 'Clear skies' },
temperature: { value: 20, unit: 'C' },
time: { start: '00:00', end: '00:00' },
location: { value: 'Unknown Location' }
}, null, 2),
characterThoughts: JSON.stringify({
characters: []
}, null, 2),
level: 1, // User's character level level: 1, // User's character level
classicStats: { classicStats: {
str: 10, str: 10,
@@ -187,11 +209,15 @@ export let extensionSettings = {
stored: 'list', // 'list' or 'grid' view mode for Stored section stored: 'list', // 'list' or 'grid' view mode for Stored section
assets: 'list' // 'list' or 'grid' view mode for Assets section assets: 'list' // 'list' or 'grid' view mode for Assets section
}, },
debugMode: false, // Enable debug logging visible in UI (for mobile debugging)
memoryMessagesToProcess: 16, // Number of messages to process per batch in memory recollection
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI) npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
// Combat encounter settings
encounterSettings: {
enabled: true, // Show Start Encounter button above chat input
historyDepth: 8, // Number of recent messages to include in combat initialization
autoSaveLogs: false // Save detailed combat logs to file
},
// Auto avatar generation settings // Auto avatar generation settings
autoGenerateAvatars: false, // Master toggle for auto-generating avatars autoGenerateAvatars: true, // Master toggle for auto-generating avatars
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
// External API settings for 'external' generation mode // External API settings for 'external' generation mode
externalApiSettings: { externalApiSettings: {
@@ -200,6 +226,30 @@ export let extensionSettings = {
model: '', // Model identifier (e.g., "gpt-4o-mini") model: '', // Model identifier (e.g., "gpt-4o-mini")
maxTokens: 8192, // Maximum tokens for generation maxTokens: 8192, // Maximum tokens for generation
temperature: 0.7 // Temperature setting for generation temperature: 0.7 // Temperature setting for generation
},
// Lock state for tracker items (v3 JSON format feature)
lockedItems: {
stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"])
skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"])
inventory: {
onPerson: [], // Array of locked item indices (e.g., [0, 2])
clothing: [], // Array of locked item indices
stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]})
assets: [] // Array of locked asset indices
},
quests: {
main: false, // Boolean for main quest lock
optional: [] // Array of locked optional quest indices (e.g., [0, 2])
},
infoBox: {
date: false, // Boolean for date widget lock
weather: false, // Boolean for weather widget lock
temperature: false, // Boolean for temperature widget lock
time: false, // Boolean for time widget lock
location: false, // Boolean for location widget lock
recentEvents: false // Boolean for recent events widget lock
},
characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}})
} }
}; };
@@ -326,11 +376,24 @@ export function updateLastGeneratedData(updates) {
} }
export function setCommittedTrackerData(data) { export function setCommittedTrackerData(data) {
console.log('[RPG State] setCommittedTrackerData called with:', data);
console.log('[RPG State] Type check on input:', {
userStatsType: typeof data.userStats,
infoBoxType: typeof data.infoBox,
characterThoughtsType: typeof data.characterThoughts,
userStatsValue: data.userStats,
infoBoxValue: data.infoBox,
characterThoughtsValue: data.characterThoughts
});
committedTrackerData = data; committedTrackerData = data;
console.log('[RPG State] committedTrackerData after assignment:', committedTrackerData);
} }
export function updateCommittedTrackerData(updates) { export function updateCommittedTrackerData(updates) {
console.log('[RPG State] updateCommittedTrackerData called with:', updates);
console.log('[RPG State] committedTrackerData before update:', committedTrackerData);
Object.assign(committedTrackerData, updates); Object.assign(committedTrackerData, updates);
console.log('[RPG State] committedTrackerData after update:', committedTrackerData);
} }
export function setLastActionWasSwipe(value) { export function setLastActionWasSwipe(value) {
+45 -20
View File
@@ -17,41 +17,66 @@
"template.settingsModal.themeOptions.custom.text": "Text:", "template.settingsModal.themeOptions.custom.text": "Text:",
"template.settingsModal.themeOptions.custom.highlight": "Highlight:", "template.settingsModal.themeOptions.custom.highlight": "Highlight:",
"template.settingsModal.theme.statBarLow": "Stat Bar Color (Low):", "template.settingsModal.theme.statBarLow": "Stat Bar Color (Low):",
"template.settingsModal.theme.statBarLowNote": "Color when stats are at 0%", "template.settingsModal.theme.statBarLowNote": "Color when stats are at 0%.",
"template.settingsModal.theme.statBarHigh": "Stat Bar Color (High):", "template.settingsModal.theme.statBarHigh": "Stat Bar Color (High):",
"template.settingsModal.theme.statBarHighNote": "Color when stats are at 100%", "template.settingsModal.theme.statBarHighNote": "Color when stats are at 100%.",
"template.settingsModal.displayTitle": "Display Options", "template.settingsModal.displayTitle": "Display Options",
"template.settingsModal.displayNote": "Use the Extensions tab to enable/disable the RPG Companion extension.", "template.settingsModal.displayNote": "You can enable/disable the entire RPG Companion extension in the Extensions tab of the SillyTavern.",
"template.settingsModal.display.panelPosition": "Panel Position:", "template.settingsModal.display.panelPosition": "Panel Position:",
"template.settingsModal.display.panelPositionOptions.right": "Right Sidebar", "template.settingsModal.display.panelPositionOptions.right": "Right Sidebar",
"template.settingsModal.display.panelPositionOptions.left": "Left Sidebar", "template.settingsModal.display.panelPositionOptions.left": "Left Sidebar",
"template.settingsModal.display.toggleAutoUpdate": "Auto-update after messages", "template.settingsModal.display.toggleAutoUpdate": "Auto-update after messages",
"template.settingsModal.display.showUserStats": "Show User Stats", "template.settingsModal.display.showUserStats": "Show User Stats",
"template.settingsModal.display.showUserStats": "Show User Stats",
"template.settingsModal.display.showUserStatsNote": "Enable User Stats that track your persona's statistics, mood, attributes, skills, etc.",
"template.settingsModal.display.showInfoBox": "Show Info Box", "template.settingsModal.display.showInfoBox": "Show Info Box",
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters", "template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.",
"template.settingsModal.display.toggleAutoUpdate": "Auto-update after messages",
"template.settingsModal.display.toggleAutoUpdateNote": "Automatically refresh RPG info after each message.",
"template.settingsModal.display.narratorMode": "Narrator Mode", "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.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory", "template.settingsModal.display.showInventory": "Show Inventory",
"template.settingsModal.display.showInventoryNote": "Track items carried, clothing worn, stored items, and assets.",
"template.settingsModal.display.showQuests": "Show Quests", "template.settingsModal.display.showQuests": "Show Quests",
"template.settingsModal.display.showThoughtsInChat": "Show Thoughts in Chat", "template.settingsModal.display.showQuestsNote": "Manage main and optional quests with objectives.",
"template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages", "template.settingsModal.display.showLockIcons": "Show Locking/Unlocking Trackers",
"template.settingsModal.display.showLockIconsNote": "Display lock/unlock icons on tracker items to prevent AI from modifying them.",
"template.settingsModal.display.showThoughtsInChat": "Show Thoughts",
"template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Always Show Thought Bubble", "template.settingsModal.display.alwaysShowThoughtBubble": "Always Show Thought Bubble",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first", "template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first",
"template.settingsModal.display.enableAnimations": "Enable Animations", "template.settingsModal.display.enableAnimations": "Enable Animations",
"template.settingsModal.display.enableAnimationsNote": "Smooth transitions for stats, content updates, and dice rolls", "template.settingsModal.display.enableAnimationsNote": "Smooth transitions for stats, content updates, and dice rolls.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Show Immersive HTML", "template.settingsModal.display.showImmersiveHtmlToggle": "Show Immersive HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Display a toggle button to enable/disable HTML formatting in messages.",
"template.settingsModal.display.showDialogueColoringToggle": "Show Colored Dialogues",
"template.settingsModal.display.showDialogueColoringToggleNote": "Display a toggle button to enable/disable colored dialogue formatting.",
"template.settingsModal.display.showSpotifyMusicToggle": "Show Spotify Music", "template.settingsModal.display.showSpotifyMusicToggle": "Show Spotify Music",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Display Spotify music player with AI-suggested scene-appropriate tracks.",
"template.settingsModal.display.showSnowflakesToggle": "Show Snowflakes Effect", "template.settingsModal.display.showSnowflakesToggle": "Show Snowflakes Effect",
"template.settingsModal.display.showDynamicWeatherToggle": "Show Dynamic Weather Effects", "template.settingsModal.display.showDynamicWeatherToggle": "Show Dynamic Weather Effects",
"template.settingsModal.display.showPlotProgressionButtons": "Show Plot Progression Buttons", "template.settingsModal.display.showDynamicWeatherToggleNote": "Display a toggle button to enable/disable animated weather effects.",
"template.settingsModal.display.showPlotProgressionButtonsNote": "Display buttons above chat input for plot progression prompts", "template.settingsModal.display.showNarratorMode": "Show Narrator Mode",
"template.settingsModal.display.showNarratorModeNote": "Display a toggle button to enable/disable narrator mode (infer characters from context).",
"template.settingsModal.display.showAutoAvatars": "Show Auto-generate Avatars",
"template.settingsModal.display.showAutoAvatarsNote": "Display a toggle button to automatically generate avatars for characters without images.",
"template.settingsModal.display.showRandomizedPlot": "Show Randomized Plot Progression",
"template.settingsModal.display.showRandomizedPlotNote": "Display button for AI-generated random plot progression prompts.",
"template.settingsModal.display.showNaturalPlot": "Show Natural Plot Progression",
"template.settingsModal.display.showNaturalPlotNote": "Display button for context-aware narrative continuation prompts.",
"template.settingsModal.display.showStartEncounter": "Show Start Encounter",
"template.settingsModal.display.showStartEncounterNote": "Display button to initiate interactive combat encounters.",
"template.settingsModal.display.showDiceDisplay": "Show Dice Roll Display", "template.settingsModal.display.showDiceDisplay": "Show Dice Roll Display",
"template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.", "template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.",
"template.settingsModal.display.enableDebugMode": "Enable Debug Mode", "template.mainPanel.autoAvatars": "Auto Avatars",
"template.settingsModal.display.enableDebugModeNote": "Shows parser logs in a mobile-friendly UI panel. Useful for troubleshooting. Look for the red bug button.",
"template.settingsModal.display.autoGenerateAvatars": "Auto-generate Missing Avatars",
"template.settingsModal.display.autoGenerateAvatarsNote": "Automatically generate avatars for characters without custom images using the Image Generation Plugin",
"template.settingsModal.advancedTitle": "Advanced", "template.settingsModal.advancedTitle": "Advanced",
"template.settingsModal.advanced.encounterHistoryDepth": "Chat History Depth For Encounters:",
"template.settingsModal.advanced.encounterHistoryDepthNote": "Number of recent messages to include in combat initialization.",
"template.settingsModal.advanced.autoSaveCombatLogs": "Auto-save Combat Logs",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "Save detailed combat logs to file for future reference and analysis.",
"template.settingsModal.advanced.clearCacheNote": "Clears all cached data including tracker history and temporary files.",
"template.settingsModal.advanced.generationMode": "Generation Mode:", "template.settingsModal.advanced.generationMode": "Generation Mode:",
"template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation", "template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation",
"template.settingsModal.advanced.generationModeOptions.separate": "Separate Generation", "template.settingsModal.advanced.generationModeOptions.separate": "Separate Generation",
@@ -59,21 +84,19 @@
"template.settingsModal.advanced.generationModeOptions.external": "External API", "template.settingsModal.advanced.generationModeOptions.external": "External API",
"template.settingsModal.advanced.externalApi.title": "External API Settings", "template.settingsModal.advanced.externalApi.title": "External API Settings",
"template.settingsModal.advanced.externalApi.baseUrl": "API Base URL", "template.settingsModal.advanced.externalApi.baseUrl": "API Base URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI-compatible endpoint (e.g., OpenAI, OpenRouter, local LLM server)", "template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI-compatible endpoint (e.g., OpenAI, OpenRouter, local LLM server).",
"template.settingsModal.advanced.externalApi.apiKey": "API Key", "template.settingsModal.advanced.externalApi.apiKey": "API Key",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Your API key for the external service", "template.settingsModal.advanced.externalApi.apiKeyNote": "Your API key for the external service.",
"template.settingsModal.advanced.externalApi.model": "Model", "template.settingsModal.advanced.externalApi.model": "Model",
"template.settingsModal.advanced.externalApi.modelNote": "Model identifier (e.g., gpt-4o-mini, claude-3-haiku, mistral-7b)", "template.settingsModal.advanced.externalApi.modelNote": "Model identifier (e.g., gpt-4o-mini, claude-3-haiku, mistral-7b).",
"template.settingsModal.advanced.externalApi.maxTokens": "Max Tokens", "template.settingsModal.advanced.externalApi.maxTokens": "Max Tokens",
"template.settingsModal.advanced.externalApi.temperature": "Temperature", "template.settingsModal.advanced.externalApi.temperature": "Temperature",
"template.settingsModal.advanced.externalApi.testConnection": "Test Connection", "template.settingsModal.advanced.externalApi.testConnection": "Test Connection",
"template.settingsModal.advanced.contextMessages": "Context Messages:", "template.settingsModal.advanced.contextMessages": "Context Messages:",
"template.settingsModal.advanced.contextMessagesNote": "Number of recent messages to include (Separate mode only)", "template.settingsModal.advanced.contextMessagesNote": "Number of recent messages to include.",
"template.settingsModal.advanced.memoryBatchSize": "Memory Batch Size:",
"template.settingsModal.advanced.memoryBatchSizeNote": "Number of messages to process per batch in Memory Recollection",
"template.settingsModal.advanced.useSeparatePreset": "Use model connected to RPG Companion Trackers preset", "template.settingsModal.advanced.useSeparatePreset": "Use model connected to RPG Companion Trackers preset",
"template.settingsModal.advanced.useSeparatePresetNote": "Separate mode only. When enabled, tracker generation will use the model from the \"RPG Companion Trackers\" preset instead of your main API model. The preset will be switched automatically during generation and restored afterward. Select the desired model in that preset and make sure the \"Bind presets to API connections\" toggle is on (next to the import/export preset buttons).", "template.settingsModal.advanced.useSeparatePresetNote": "When enabled, tracker generation will use the model from the \"RPG Companion Trackers\" preset instead of your main API model. The preset will be switched automatically during generation and restored afterward. Select the desired model in that preset and make sure the \"Bind presets to API connections\" toggle is on (next to the import/export preset buttons).",
"template.settingsModal.advanced.skipInjections": "Skip Injections during Guided Generations:", "template.settingsModal.advanced.skipInjections": "Skip Injections During Guided Generations:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Never skip", "template.settingsModal.advanced.skipInjectionsOptions.none": "Never skip",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Only on impersonation requests", "template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Only on impersonation requests",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Always for guided or quiet prompts", "template.settingsModal.advanced.skipInjectionsOptions.guided": "Always for guided or quiet prompts",
@@ -139,9 +162,11 @@
"template.mainPanel.lastRoll": "Last Roll:", "template.mainPanel.lastRoll": "Last Roll:",
"template.mainPanel.clearLastRoll": "Clear last roll", "template.mainPanel.clearLastRoll": "Clear last roll",
"template.mainPanel.immersiveHtml": "Immersive HTML", "template.mainPanel.immersiveHtml": "Immersive HTML",
"template.mainPanel.coloredDialogues": "Colored Dialogues",
"template.mainPanel.spotifyMusic": "Spotify Music", "template.mainPanel.spotifyMusic": "Spotify Music",
"template.mainPanel.snowflakesEffect": "Snowflakes Effect", "template.mainPanel.snowflakesEffect": "Snowflakes Effect",
"template.mainPanel.dynamicWeatherEffects": "Dynamic Weather", "template.mainPanel.dynamicWeatherEffects": "Dynamic Weather",
"template.mainPanel.narratorMode": "Narrator Mode",
"template.mainPanel.refreshRpgInfo": "Refresh RPG Info", "template.mainPanel.refreshRpgInfo": "Refresh RPG Info",
"template.mainPanel.updating": "Updating...", "template.mainPanel.updating": "Updating...",
"template.mainPanel.editTrackersButton": "Edit Trackers", "template.mainPanel.editTrackersButton": "Edit Trackers",
+7 -4
View File
@@ -30,6 +30,8 @@
"template.settingsModal.display.showInfoBox": "顯示資訊框", "template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色", "template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showInventory": "顯示物品欄", "template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
"template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法", "template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法",
"template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡", "template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡",
"template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡", "template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡",
@@ -37,11 +39,11 @@
"template.settingsModal.display.enableAnimations": "啟用動畫", "template.settingsModal.display.enableAnimations": "啟用動畫",
"template.settingsModal.display.enableAnimationsNote": "屬性、內容更新和擲骰的動畫效果", "template.settingsModal.display.enableAnimationsNote": "屬性、內容更新和擲骰的動畫效果",
"template.settingsModal.display.showImmersiveHtmlToggle": "顯示沉浸式 HTML", "template.settingsModal.display.showImmersiveHtmlToggle": "顯示沉浸式 HTML",
"template.settingsModal.display.showDialogueColoringToggle": "顯示彩色對話",
"template.settingsModal.display.showDialogueColoringToggleNote": "顯示一個切換按鈕以啟用/停用彩色對話格式。",
"template.settingsModal.display.showSpotifyMusicToggle": "顯示 Spotify 音樂", "template.settingsModal.display.showSpotifyMusicToggle": "顯示 Spotify 音樂",
"template.settingsModal.display.showSnowflakesToggle": "顯示雪花效果", "template.settingsModal.display.showDynamicWeatherToggle": "顯示動態天氣效果", "template.settingsModal.display.showPlotProgressionButtons": "顯示劇情推進按鈕(QR", "template.settingsModal.display.showSnowflakesToggle": "顯示雪花效果", "template.settingsModal.display.showDynamicWeatherToggle": "顯示動態天氣效果", "template.settingsModal.display.showNarratorMode": "顯示旁白模式", "template.settingsModal.display.showNarratorModeNote": "顯示切換按鈕以啟用/停用旁白模式", "template.settingsModal.display.showAutoAvatars": "顯示自動生成頭像", "template.settingsModal.display.showAutoAvatarsNote": "顯示切換按鈕以自動為沒有圖片的角色生成頭像", "template.settingsModal.display.showPlotProgressionButtons": "顯示劇情推進按鈕(QR",
"template.settingsModal.display.showPlotProgressionButtonsNote": "在聊天輸入框上方顯示劇情推進提示按鈕(QR)", "template.settingsModal.display.showPlotProgressionButtonsNote": "在聊天輸入框上方顯示劇情推進提示按鈕(QR)",
"template.settingsModal.display.enableDebugMode": "Debug Mode",
"template.settingsModal.display.enableDebugModeNote": "UI 面板中顯示日誌,對於故障排除很有用。",
"template.settingsModal.advancedTitle": "進階", "template.settingsModal.advancedTitle": "進階",
"template.settingsModal.advanced.generationMode": "生成模式:", "template.settingsModal.advanced.generationMode": "生成模式:",
"template.settingsModal.advanced.generationModeOptions.together": "同時生成", "template.settingsModal.advanced.generationModeOptions.together": "同時生成",
@@ -130,8 +132,9 @@
"template.mainPanel.lastRoll": "上次擲骰:", "template.mainPanel.lastRoll": "上次擲骰:",
"template.mainPanel.clearLastRoll": "清除上次擲骰", "template.mainPanel.clearLastRoll": "清除上次擲骰",
"template.mainPanel.immersiveHtml": "沉浸式 HTML", "template.mainPanel.immersiveHtml": "沉浸式 HTML",
"template.mainPanel.coloredDialogues": "彩色對話",
"template.mainPanel.spotifyMusic": "Spotify 音樂", "template.mainPanel.spotifyMusic": "Spotify 音樂",
"template.mainPanel.snowflakesEffect": "雪花效果", "template.mainPanel.dynamicWeatherEffects": "動態天氣", "template.mainPanel.refreshRpgInfo": "刷新資訊", "template.mainPanel.snowflakesEffect": "雪花效果", "template.mainPanel.dynamicWeatherEffects": "動態天氣", "template.mainPanel.narratorMode": "旁白模式", "template.mainPanel.autoAvatars": "自動頭像", "template.mainPanel.refreshRpgInfo": "刷新資訊",
"template.mainPanel.updating": "更新中...", "template.mainPanel.updating": "更新中...",
"template.mainPanel.editTrackersButton": "追蹤器編輯", "template.mainPanel.editTrackersButton": "追蹤器編輯",
"template.mainPanel.settingsButton": "設定", "template.mainPanel.settingsButton": "設定",
-16
View File
@@ -220,16 +220,6 @@ export async function regenerateAvatar(characterName) {
delete sessionAvatarPrompts[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 { try {
// Generate new LLM prompt // Generate new LLM prompt
const prompt = await generateAvatarPrompt(characterName); const prompt = await generateAvatarPrompt(characterName);
@@ -237,12 +227,6 @@ export async function regenerateAvatar(characterName) {
// Generate the avatar // Generate the avatar
return await generateSingleAvatar(characterName, prompt); return await generateSingleAvatar(characterName, prompt);
} finally { } 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 // Remove from pending when done
pendingGenerations.delete(characterName); pendingGenerations.delete(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 // 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 { togglePlotButtons } from '../ui/layout.js';
import { extensionSettings, setIsPlotProgression } from '../../core/state.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'; import { Generate } from '../../../../../../../script.js';
/** /**
@@ -34,8 +34,8 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
margin: 0 2px; margin: 0 2px;
" tabindex="0" role="button"> " tabindex="0" role="button" title="Generate a random plot twist or event">
<i class="fa-solid fa-dice"></i> <span class="rpg-btn-text">Randomized Plot</span> <i class="fa-solid fa-dice"></i>&nbsp;<span class="rpg-btn-text">Randomized Plot</span>
</button> </button>
<button id="rpg-plot-natural" class="menu_button interactable" style=" <button id="rpg-plot-natural" class="menu_button interactable" style="
background-color: #4a90e2; background-color: #4a90e2;
@@ -46,8 +46,8 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
margin: 0 2px; margin: 0 2px;
" tabindex="0" role="button"> " tabindex="0" role="button" title="Continue the story naturally without twists">
<i class="fa-solid fa-forward"></i> <span class="rpg-btn-text">Natural Plot</span> <i class="fa-solid fa-forward"></i>&nbsp;<span class="rpg-btn-text">Natural Plot</span>
</button> </button>
<button id="rpg-encounter-button" class="menu_button interactable" style=" <button id="rpg-encounter-button" class="menu_button interactable" style="
background-color: #cc3333; background-color: #cc3333;
@@ -59,7 +59,7 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
cursor: pointer; cursor: pointer;
margin: 0 2px; margin: 0 2px;
" tabindex="0" role="button" title="Enter combat encounter"> " 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> </button>
</span> </span>
`; `;
@@ -114,6 +114,13 @@ export async function sendPlotProgression(type) {
prompt += '\n\n' + htmlPromptText; 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 // Set flag to indicate we're doing plot progression
// This will be used by onMessageReceived to clear the prompt after generation completes // This will be used by onMessageReceived to clear the prompt after generation completes
setIsPlotProgression(true); setIsPlotProgression(true);
+13 -23
View File
@@ -23,6 +23,7 @@ import { parseResponse, parseUserStats } from './parser.js';
import { parseAndStoreSpotifyUrl } from '../features/musicPlayer.js'; import { parseAndStoreSpotifyUrl } from '../features/musicPlayer.js';
import { renderUserStats } from '../rendering/userStats.js'; import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js'; import { renderInfoBox } from '../rendering/infoBox.js';
import { removeLocks } from './lockManager.js';
import { renderThoughts } from '../rendering/thoughts.js'; import { renderThoughts } from '../rendering/thoughts.js';
import { renderInventory } from '../rendering/inventory.js'; import { renderInventory } from '../rendering/inventory.js';
import { renderQuests } from '../rendering/quests.js'; import { renderQuests } from '../rendering/quests.js';
@@ -235,22 +236,6 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
const updatingText = i18n.getTranslation('template.mainPanel.updating') || 'Updating...'; const updatingText = i18n.getTranslation('template.mainPanel.updating') || 'Updating...';
$updateBtn.html(`<i class="fa-solid fa-spinner fa-spin"></i> ${updatingText}`).prop('disabled', true); $updateBtn.html(`<i class="fa-solid fa-spinner fa-spin"></i> ${updatingText}`).prop('disabled', true);
// Save current preset name before switching (if we're going to switch)
// Note: Preset switching is only used in separate mode, not external mode
if (!isExternalMode && extensionSettings.useSeparatePreset) {
originalPresetName = await getCurrentPresetName();
console.log(`[RPG Companion] Saved original preset: "${originalPresetName}"`);
}
// Switch to separate preset if enabled (separate mode only)
if (!isExternalMode && extensionSettings.useSeparatePreset) {
const switched = await switchToPreset('RPG Companion Trackers');
if (!switched) {
console.warn('[RPG Companion] Failed to switch to RPG Companion Trackers preset. Using current preset.');
originalPresetName = null; // Don't try to restore if we didn't switch
}
}
const prompt = await generateSeparateUpdatePrompt(); const prompt = await generateSeparateUpdatePrompt();
// Generate response based on mode // Generate response based on mode
@@ -270,6 +255,18 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
if (response) { if (response) {
// console.log('[RPG Companion] Raw AI response:', response); // console.log('[RPG Companion] Raw AI response:', response);
const parsedData = parseResponse(response); const parsedData = parseResponse(response);
// Remove locks from parsed data (JSON format only, text format is unaffected)
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
// Parse and store Spotify URL if feature is enabled // Parse and store Spotify URL if feature is enabled
parseAndStoreSpotifyUrl(response); parseAndStoreSpotifyUrl(response);
// console.log('[RPG Companion] Parsed data:', parsedData); // console.log('[RPG Companion] Parsed data:', parsedData);
@@ -383,13 +380,6 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
toastr.error(error.message, 'RPG Companion External API Error'); toastr.error(error.message, 'RPG Companion External API Error');
} }
} finally { } finally {
// Restore original preset if we switched to a separate one
if (originalPresetName && extensionSettings.useSeparatePreset) {
console.log(`[RPG Companion] Restoring original preset: "${originalPresetName}"`);
await switchToPreset(originalPresetName);
originalPresetName = null; // Clear after restoring
}
setIsGenerating(false); setIsGenerating(false);
// Restore button to original state // Restore button to original state
+45 -6
View File
@@ -8,7 +8,9 @@ import { chat, characters, this_chid, substituteParams } from '../../../../../..
import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js'; import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData } from '../../core/state.js'; import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { currentEncounter } from '../features/encounterState.js'; import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js'; import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.js';
/** /**
* Gets character information from the current chat * Gets character information from the current chat
@@ -233,7 +235,9 @@ export async function buildEncounterInitPrompt() {
if (extensionSettings.classicStats) { if (extensionSettings.classicStats) {
const stats = extensionSettings.classicStats; const stats = extensionSettings.classicStats;
userStatsInfo += `${userName}'s Attributes: `; userStatsInfo += `${userName}'s Attributes: `;
userStatsInfo += `STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}, LVL ${extensionSettings.level}\n\n`; const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false;
const levelStr = showLevel ? `, LVL ${extensionSettings.level}` : '';
userStatsInfo += `STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}${levelStr}\n\n`;
} }
// Add present characters info for party members // Add present characters info for party members
@@ -417,7 +421,18 @@ export async function buildCombatActionPrompt(action, combatStats) {
// Add ONLY classic stats/attributes if enabled // Add ONLY classic stats/attributes if enabled
if (extensionSettings.classicStats) { if (extensionSettings.classicStats) {
const stats = extensionSettings.classicStats; const stats = extensionSettings.classicStats;
systemMessage += `\nAttributes: STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}, LVL ${extensionSettings.level}\n`; const config = extensionSettings.trackerConfig?.userStats;
const rpgAttributes = (config?.rpgAttributes && config.rpgAttributes.length > 0) ? config.rpgAttributes : [
{ id: 'str', name: 'STR', enabled: true },
{ id: 'dex', name: 'DEX', enabled: true },
{ id: 'con', name: 'CON', enabled: true },
{ id: 'int', name: 'INT', enabled: true },
{ id: 'wis', name: 'WIS', enabled: true },
{ id: 'cha', name: 'CHA', enabled: true }
];
const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id);
const attributeStrings = enabledAttributes.map(attr => `${attr.name} ${stats[attr.id] || 10}`);
systemMessage += `\nAttributes: ${attributeStrings.join(', ')}, LVL ${extensionSettings.level}\n`;
} }
systemMessage += `</persona>\n\n`; systemMessage += `</persona>\n\n`;
@@ -658,15 +673,24 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
summaryMessage += `<previous>\n`; summaryMessage += `<previous>\n`;
if (committedTrackerData.userStats) { if (committedTrackerData.userStats) {
summaryMessage += `${userName}'s Stats:\n${committedTrackerData.userStats}\n\n`; const statsJSON = typeof committedTrackerData.userStats === 'object'
? JSON.stringify(committedTrackerData.userStats, null, 2)
: committedTrackerData.userStats;
summaryMessage += statsJSON + '\n';
} }
if (committedTrackerData.infoBox) { if (committedTrackerData.infoBox) {
summaryMessage += `Info Box:\n${committedTrackerData.infoBox}\n\n`; const infoBoxJSON = typeof committedTrackerData.infoBox === 'object'
? JSON.stringify(committedTrackerData.infoBox, null, 2)
: committedTrackerData.infoBox;
summaryMessage += infoBoxJSON + '\n';
} }
if (committedTrackerData.characterThoughts) { if (committedTrackerData.characterThoughts) {
summaryMessage += `Present Characters:\n${committedTrackerData.characterThoughts}\n\n`; const charactersJSON = typeof committedTrackerData.characterThoughts === 'object'
? JSON.stringify(committedTrackerData.characterThoughts, null, 2)
: committedTrackerData.characterThoughts;
summaryMessage += charactersJSON + '\n';
} }
summaryMessage += `</previous>\n\n`; summaryMessage += `</previous>\n\n`;
@@ -712,7 +736,22 @@ export function parseEncounterJSON(response) {
cleaned = cleaned.substring(firstBrace, lastBrace + 1); cleaned = cleaned.substring(firstBrace, lastBrace + 1);
} }
return JSON.parse(cleaned); // Try to parse directly first
try {
return JSON.parse(cleaned);
} catch (initialError) {
// If direct parsing fails, try JSON repair
console.warn('[RPG Companion] Initial parse failed, attempting JSON repair...');
const repaired = repairJSON(cleaned);
if (repaired) {
console.log('[RPG Companion] ✓ Successfully repaired encounter JSON');
return repaired;
}
// If repair also failed, throw the original error
throw initialError;
}
} catch (error) { } catch (error) {
console.error('[RPG Companion] Failed to parse encounter JSON:', error); console.error('[RPG Companion] Failed to parse encounter JSON:', error);
console.error('[RPG Companion] Response was:', response); console.error('[RPG Companion] Response was:', response);
+123 -74
View File
@@ -11,7 +11,8 @@ import {
lastGeneratedData, lastGeneratedData,
isGenerating, isGenerating,
lastActionWasSwipe, lastActionWasSwipe,
setLastActionWasSwipe setLastActionWasSwipe,
setIsGenerating
} from '../../core/state.js'; } from '../../core/state.js';
import { evaluateSuppression } from './suppression.js'; import { evaluateSuppression } from './suppression.js';
import { parseUserStats } from './parser.js'; import { parseUserStats } from './parser.js';
@@ -20,24 +21,38 @@ import {
generateTrackerInstructions, generateTrackerInstructions,
generateContextualSummary, generateContextualSummary,
DEFAULT_HTML_PROMPT, DEFAULT_HTML_PROMPT,
DEFAULT_DIALOGUE_COLORING_PROMPT,
DEFAULT_SPOTIFY_PROMPT, DEFAULT_SPOTIFY_PROMPT,
SPOTIFY_FORMAT_INSTRUCTION SPOTIFY_FORMAT_INSTRUCTION
} from './promptBuilder.js'; } from './promptBuilder.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
// Track last chat length we committed at to prevent duplicate commits from streaming
let lastCommittedChatLength = -1;
/** /**
* Event handler for generation start. * Event handler for generation start.
* Manages tracker data commitment and prompt injection based on generation mode. * Manages tracker data commitment and prompt injection based on generation mode.
* *
* @param {string} type - Event type * @param {string} type - Event type
* @param {Object} data - Event data * @param {Object} data - Event data
* @param {boolean} dryRun - If true, this is a dry run (page reload, prompt preview, etc.) - skip all logic
*/ */
export async function onGenerationStarted(type, data) { export async function onGenerationStarted(type, data, dryRun) {
// console.log('[RPG Companion] onGenerationStarted called'); // Skip dry runs (page reload, prompt manager preview, etc.)
// console.log('[RPG Companion] enabled:', extensionSettings.enabled); if (dryRun) {
// console.log('[RPG Companion] generationMode:', extensionSettings.generationMode); console.log('[RPG Companion] Skipping onGenerationStarted: dry run detected');
// console.log('[RPG Companion] ⚡ EVENT: onGenerationStarted - lastActionWasSwipe =', lastActionWasSwipe, '| isGenerating =', isGenerating); return;
// console.log('[RPG Companion] Committed Prompt:', committedTrackerData); }
console.log('[RPG Companion] onGenerationStarted called');
console.log('[RPG Companion] enabled:', extensionSettings.enabled);
console.log('[RPG Companion] generationMode:', extensionSettings.generationMode);
console.log('[RPG Companion] ⚡ EVENT: onGenerationStarted - lastActionWasSwipe =', lastActionWasSwipe, '| isGenerating =', isGenerating);
console.log('[RPG Companion] Committed Prompt:', committedTrackerData);
// Skip tracker injection for image generation requests // Skip tracker injection for image generation requests
if (data?.quietImage) { if (data?.quietImage) {
@@ -46,6 +61,13 @@ export async function onGenerationStarted(type, data) {
} }
if (!extensionSettings.enabled) { if (!extensionSettings.enabled) {
// Extension is disabled - clear any existing prompts to ensure nothing is injected
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
return; return;
} }
@@ -76,8 +98,63 @@ export async function onGenerationStarted(type, data) {
// Ensure checkpoint is applied before generation // Ensure checkpoint is applied before generation
await restoreCheckpointOnLoad(); await restoreCheckpointOnLoad();
const currentChatLength = chat ? chat.length : 0;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null; const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// For TOGETHER mode: Commit when user sends message (before first generation)
if (extensionSettings.generationMode === 'together') {
// By the time onGenerationStarted fires, ST has already added the placeholder AI message
// So we check the second-to-last message to see if user just sent a message
const secondToLastMessage = chat && chat.length > 1 ? chat[chat.length - 2] : null;
const isUserMessage = secondToLastMessage && secondToLastMessage.is_user;
// Commit if:
// 1. Second-to-last message is from USER (user just sent message)
// 2. Not a swipe (lastActionWasSwipe = false)
// 3. Haven't already committed for this chat length (prevent streaming duplicates)
const shouldCommit = isUserMessage && !lastActionWasSwipe && currentChatLength !== lastCommittedChatLength;
if (shouldCommit) {
console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: User sent message - committing data from BEFORE user message');
console.log('[RPG Companion] Chat length:', currentChatLength, 'Last committed:', lastCommittedChatLength);
console.log('[RPG Companion] BEFORE: committedTrackerData =', {
userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
});
console.log('[RPG Companion] BEFORE: lastGeneratedData =', {
userStats: lastGeneratedData.userStats ? `${lastGeneratedData.userStats.substring(0, 50)}...` : 'null',
infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
characterThoughts: lastGeneratedData.characterThoughts ? `${lastGeneratedData.characterThoughts.substring(0, 100)}...` : 'null'
});
// Commit displayed data (from before user sent message)
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// Track chat length to prevent duplicate commits
lastCommittedChatLength = currentChatLength;
console.log('[RPG Companion] AFTER: committedTrackerData =', {
userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
});
} else if (lastActionWasSwipe) {
console.log('[RPG Companion] ⏭️ Skipping commit: swipe (using previous committed data)');
} else if (!isUserMessage) {
console.log('[RPG Companion] ⏭️ Skipping commit: second-to-last message is not user message (likely swipe or continuation)');
}
console.log('[RPG Companion] 📦 TOGETHER MODE: Injecting committed tracker data into prompt');
console.log('[RPG Companion] committedTrackerData =', {
userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
});
}
// For SEPARATE mode only: Check if we need to commit extension data // For SEPARATE mode only: Check if we need to commit extension data
// BUT: Only do this for the MAIN generation, not the tracker update generation // BUT: Only do this for the MAIN generation, not the tracker update generation
// If isGenerating is true, this is the tracker update generation (second call), so skip flag logic // If isGenerating is true, this is the tracker update generation (second call), so skip flag logic
@@ -85,83 +162,39 @@ export async function onGenerationStarted(type, data) {
if (extensionSettings.generationMode === 'separate' && !isGenerating) { if (extensionSettings.generationMode === 'separate' && !isGenerating) {
if (!lastActionWasSwipe) { if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation // User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData'); console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData');
// console.log('[RPG Companion] BEFORE commit - committedTrackerData:', { console.log('[RPG Companion] BEFORE commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null', userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null', infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// }); });
// console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', { console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null', userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null' characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// }); });
committedTrackerData.userStats = lastGeneratedData.userStats; committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox; committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] AFTER commit - committedTrackerData:', { console.log('[RPG Companion] AFTER commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null', userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null', infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// }); });
// Reset flag after committing (ready for next cycle) // Reset flag after committing (ready for next cycle)
} else { } else {
// console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)'); console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)');
// console.log('[RPG Companion] committedTrackerData:', { console.log('[RPG Companion] committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null', userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null', infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// }); });
// Reset flag after using it (swipe generation complete, ready for next action) // Reset flag after using it (swipe generation complete, ready for next action)
} }
} }
// For TOGETHER mode: Check if we need to commit extension data
// Only commit when user sends a new message (not on swipes)
if (extensionSettings.generationMode === 'together') {
if (!lastActionWasSwipe) {
// User sent a new message - commit data from the last assistant message they replied to
// This ensures swipes use consistent data from before the first swipe
console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: New message - committing from last assistant message');
// Find the last assistant message (before the user's new message)
const chat = getContext().chat;
let foundAssistantMessage = false;
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
// Found last assistant message - commit its stored tracker data
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
const swipeData = message.extra.rpg_companion_swipes[swipeId];
if (swipeData) {
committedTrackerData.userStats = swipeData.userStats || null;
committedTrackerData.infoBox = swipeData.infoBox || null;
committedTrackerData.characterThoughts = swipeData.characterThoughts || null;
foundAssistantMessage = true;
console.log('[RPG Companion] ✓ Committed tracker data from message swipe', swipeId);
}
}
break;
}
}
// Fallback: if no stored data found, use lastGeneratedData (for first message)
if (!foundAssistantMessage) {
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
console.log('[RPG Companion] ⚠ No stored message data found, using lastGeneratedData as fallback');
}
} else {
console.log('[RPG Companion] 🔄 TOGETHER MODE SWIPE: Using existing committedTrackerData (no commit)');
}
}
// Use the committed tracker data as source for generation // Use the committed tracker data as source for generation
// console.log('[RPG Companion] Using committedTrackerData for generation'); // console.log('[RPG Companion] Using committedTrackerData for generation');
// console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats); // console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats);
@@ -175,7 +208,10 @@ export async function onGenerationStarted(type, data) {
if (extensionSettings.generationMode === 'together') { if (extensionSettings.generationMode === 'together') {
// console.log('[RPG Companion] In together mode, generating prompts...'); // console.log('[RPG Companion] In together mode, generating prompts...');
const example = generateTrackerExample(); const exampleRaw = generateTrackerExample();
// Wrap example in ```json``` code blocks for consistency with format instructions
// Add only 1 newline after the closing ``` (ST adds its own newline when injecting)
const example = exampleRaw ? `\`\`\`json\n${exampleRaw}\n\`\`\`\n` : null;
// Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes // Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes
const instructions = generateTrackerInstructions(false, true); const instructions = generateTrackerInstructions(false, true);
@@ -234,6 +270,19 @@ export async function onGenerationStarted(type, data) {
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
} }
// Inject Dialogue Coloring prompt separately at depth 0 if enabled
if (extensionSettings.enableDialogueColoring && !shouldSuppress) {
// Use custom Dialogue Coloring prompt if set, otherwise use default
const dialogueColoringPromptText = extensionSettings.customDialogueColoringPrompt || DEFAULT_DIALOGUE_COLORING_PROMPT;
const dialogueColoringPrompt = `\n${dialogueColoringPromptText}`;
setExtensionPrompt('rpg-companion-dialogue-coloring', dialogueColoringPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Dialogue Coloring prompt at depth 0 for together mode');
} else {
// Clear Dialogue Coloring prompt if disabled
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled // Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) { if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default // Use custom Spotify prompt if set, otherwise use default
+209
View File
@@ -0,0 +1,209 @@
/**
* JSON Prompt Builder Helpers
* Helper functions for building JSON format tracker prompts
*/
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { getContext } from '../../../../../../extensions.js';
/**
* 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, '');
}
/**
* Builds User Stats JSON format instruction
* @returns {string} JSON format instruction for user stats
*/
export function buildUserStatsJSONInstruction() {
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig;
const userStatsConfig = trackerConfig?.userStats;
const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || [];
let instruction = '{\n';
instruction += ' "stats": [\n';
// Add stats dynamically
for (let i = 0; i < enabledStats.length; i++) {
const stat = enabledStats[i];
const comma = i < enabledStats.length - 1 ? ',' : '';
instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma}\n`;
}
instruction += ' ],\n';
// Status section
if (userStatsConfig?.statusSection?.enabled) {
instruction += ' "status": {\n';
if (userStatsConfig.statusSection.showMoodEmoji) {
instruction += ' "mood": "Mood Emoji",\n';
}
instruction += ' "conditions": "[Condition1, Condition2]"\n';
instruction += ' },\n';
}
// Skills section
if (userStatsConfig?.skillsSection?.enabled) {
instruction += ' "skills": [\n';
instruction += ' {"name": "Skill1"},\n';
instruction += ' {"name": "Skill2"}\n';
instruction += ' ],\n';
}
// Inventory section
if (extensionSettings.showInventory) {
instruction += ' "inventory": {\n';
instruction += ' "onPerson": [\n';
instruction += ' {"name": "Item1", "quantity": X},\n';
instruction += ' {"name": "Item2", "quantity": X}\n';
instruction += ' ],\n';
instruction += ' "clothing": [\n';
instruction += ' {"name": "Clothing1"}\n';
instruction += ' ],\n';
instruction += ' "stored": {\n';
instruction += ' "Location1": [\n';
instruction += ' {"name": "Item", "quantity": X}\n';
instruction += ' ]\n';
instruction += ' },\n';
instruction += ' "assets": [\n';
instruction += ' {"name": "Asset1", "location": "Location"}\n';
instruction += ' ]\n';
instruction += ' },\n';
}
// Quests section
instruction += ' "quests": {\n';
instruction += ' "main": {"title": "Quest title"},\n';
instruction += ' "optional": [\n';
instruction += ' {"title": "Quest1"},\n';
instruction += ' {"title": "Quest2"}\n';
instruction += ' ]\n';
instruction += ' }\n';
instruction += '}';
return instruction;
}
/**
* Builds Info Box JSON format instruction
* @returns {string} JSON format instruction for info box
*/
export function buildInfoBoxJSONInstruction() {
const infoBoxConfig = extensionSettings.trackerConfig?.infoBox;
const widgets = infoBoxConfig?.widgets || {};
let instruction = '{\n';
let hasFields = false;
if (widgets.date?.enabled) {
instruction += ' "date": {"value": "Weekday, Month, Year"}';
hasFields = true;
}
if (widgets.weather?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "weather": {"emoji": "Weather Emoji", "forecast": "Forecast"}';
hasFields = true;
}
if (widgets.temperature?.enabled) {
const unit = widgets.temperature.unit === 'F' ? 'F' : 'C';
instruction += (hasFields ? ',\n' : '') + ` "temperature": {"value": X, "unit": "${unit}"}`;
hasFields = true;
}
if (widgets.time?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "time": {"start": "TimeStart", "end": "TimeEnd"}';
hasFields = true;
}
if (widgets.location?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "location": {"value": "Location"}';
hasFields = true;
}
if (widgets.recentEvents?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "recentEvents": ["Event1", "Event2", "Event3"]';
hasFields = true;
}
instruction += '\n}';
return instruction;
}
/**
* Builds Present Characters JSON format instruction
* @returns {string} JSON format instruction for present characters
*/
export function buildCharactersJSONInstruction() {
const userName = getContext().name1;
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
const relationshipsEnabled = presentCharsConfig?.relationships?.enabled !== false;
const thoughtsConfig = presentCharsConfig?.thoughts;
const characterStats = presentCharsConfig?.characterStats;
const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || [];
let instruction = '[\n';
instruction += ' {\n';
instruction += ' "name": "CharacterName",\n';
instruction += ' "emoji": "Character Emoji"';
// Details fields
if (enabledFields.length > 0) {
instruction += ',\n "details": {\n';
for (let i = 0; i < enabledFields.length; i++) {
const field = enabledFields[i];
const fieldKey = toSnakeCase(field.name);
const comma = i < enabledFields.length - 1 ? ',' : '';
instruction += ` "${fieldKey}": "${field.description}"${comma}\n`;
}
instruction += ' }';
}
// Relationship
if (relationshipsEnabled) {
const relationshipFields = presentCharsConfig?.relationshipFields || [];
const options = relationshipFields.join('/');
instruction += ',\n "relationship": {"status": "(choose one: ' + options + ')"}';
}
// Stats
if (enabledCharStats.length > 0) {
instruction += ',\n "stats": [\n';
for (let i = 0; i < enabledCharStats.length; i++) {
const stat = enabledCharStats[i];
const comma = i < enabledCharStats.length - 1 ? ',' : '';
instruction += ` {"name": "${stat.name}", "value": X}${comma}\n`;
}
instruction += ' ]';
}
// Thoughts
if (thoughtsConfig?.enabled) {
const thoughtsDescription = thoughtsConfig.description || 'Internal monologue';
instruction += `,\n "thoughts": {"content": "${thoughtsDescription}"}`;
}
instruction += '\n }\n';
instruction += ']';
return instruction;
}
/**
* Adds lock information to instruction text
* @param {string} baseInstruction - Base instruction text
* @returns {string} Instruction with lock information added
*/
export function addLockInstruction(baseInstruction) {
return baseInstruction + '\n\nIMPORTANT: If an item, stat, quest, or field has "locked": true in its object, you MUST NOT change its value. Keep it exactly as it appears in the previous trackers. Only unlocked items can be modified. The "locked" field should ONLY be included if the item is actually locked - omit it for unlocked items.';
}
+463
View File
@@ -0,0 +1,463 @@
/**
* Lock Manager
* Handles applying and removing locks for tracker items
* Locks prevent AI from modifying specific values
*/
import { extensionSettings } from '../../core/state.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Apply locks to tracker data before sending to AI.
* Adds "locked": true to locked items in JSON format.
*
* @param {string} trackerData - JSON string of tracker data
* @param {string} trackerType - Type of tracker ('userStats', 'infoBox', 'characters')
* @returns {string} Tracker data with locks applied
*/
export function applyLocks(trackerData, trackerType) {
if (!trackerData) return trackerData;
// Try to parse as JSON
const parsed = repairJSON(trackerData);
if (!parsed) {
// Not JSON format, return as-is (text format doesn't support locks)
return trackerData;
}
// Get locked items for this tracker type
const lockedItems = extensionSettings.lockedItems?.[trackerType] || {};
// Apply locks based on tracker type
switch (trackerType) {
case 'userStats':
return applyUserStatsLocks(parsed, lockedItems);
case 'infoBox':
return applyInfoBoxLocks(parsed, lockedItems);
case 'characters':
return applyCharactersLocks(parsed, lockedItems);
default:
return trackerData;
}
}
/**
* Apply locks to User Stats tracker
* @param {Object} data - Parsed user stats data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyUserStatsLocks(data, lockedItems) {
// Lock individual stats within stats object
if (data.stats && lockedItems.stats) {
// Handle both section lock and individual stat locks
const isStatsLocked = lockedItems.stats === true;
if (isStatsLocked) {
// Lock entire stats section
for (const statName in data.stats) {
data.stats[statName] = {
value: data.stats[statName].value || data.stats[statName],
locked: true
};
}
} else {
// Lock individual stats
for (const statName in lockedItems.stats) {
if (lockedItems.stats[statName] && data.stats[statName] !== undefined) {
data.stats[statName] = {
value: data.stats[statName].value || data.stats[statName],
locked: true
};
}
}
}
}
// Lock status field
if (data.status && lockedItems.status) {
data.status = {
...data.status,
locked: true
};
}
// Lock individual skills
if (data.skills && lockedItems.skills) {
if (Array.isArray(data.skills)) {
data.skills = data.skills.map(skill => {
if (typeof skill === 'string') {
if (lockedItems.skills[skill]) {
return { name: skill, locked: true };
}
return skill;
} else if (skill.name && lockedItems.skills[skill.name]) {
return { ...skill, locked: true };
}
return skill;
});
}
}
// Lock inventory items - handle bracket notation paths like "inventory.onPerson[0]"
if (data.inventory && lockedItems.inventory) {
// Helper function to parse bracket notation and apply lock
const applyInventoryLocks = (items, category) => {
if (!Array.isArray(items)) return items;
return items.map((item, index) => {
// Check if this specific item is locked using bracket notation with inventory prefix
const bracketPath = `${category}[${index}]`;
if (lockedItems.inventory[bracketPath]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
}
return item;
});
};
// Apply locks to onPerson items
if (data.inventory.onPerson) {
data.inventory.onPerson = applyInventoryLocks(data.inventory.onPerson, 'onPerson');
}
// Apply locks to clothing items
if (data.inventory.clothing) {
data.inventory.clothing = applyInventoryLocks(data.inventory.clothing, 'clothing');
}
// Apply locks to assets
if (data.inventory.assets) {
data.inventory.assets = applyInventoryLocks(data.inventory.assets, 'assets');
}
// Apply locks to stored items (nested structure with inventory.stored.location[index])
if (data.inventory.stored && lockedItems.inventory.stored) {
for (const location in data.inventory.stored) {
if (Array.isArray(data.inventory.stored[location])) {
data.inventory.stored[location] = data.inventory.stored[location].map((item, index) => {
const bracketPath = `${location}[${index}]`;
if (lockedItems.inventory.stored[bracketPath]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
}
return item;
});
}
}
}
}
// Lock individual quests - handle paths like "quests.main" and "quests.optional[0]"
if (data.quests && lockedItems.quests) {
// Check if main quest is locked (entire section)
if (data.quests.main && lockedItems.quests.main === true) {
data.quests.main = { value: data.quests.main, locked: true };
}
// Check individual optional quests
if (data.quests.optional && Array.isArray(data.quests.optional)) {
data.quests.optional = data.quests.optional.map((quest, index) => {
const bracketPath = `optional[${index}]`;
if (lockedItems.quests[bracketPath]) {
return typeof quest === 'string'
? { title: quest, locked: true }
: { ...quest, locked: true };
}
return quest;
});
}
}
return JSON.stringify(data, null, 2);
}
/**
* Apply locks to Info Box tracker
* @param {Object} data - Parsed info box data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyInfoBoxLocks(data, lockedItems) {
if (lockedItems.date && data.date) {
data.date = { ...data.date, locked: true };
}
if (lockedItems.weather && data.weather) {
data.weather = { ...data.weather, locked: true };
}
if (lockedItems.temperature && data.temperature) {
data.temperature = { ...data.temperature, locked: true };
}
if (lockedItems.time && data.time) {
data.time = { ...data.time, locked: true };
}
if (lockedItems.location && data.location) {
data.location = { ...data.location, locked: true };
}
if (lockedItems.recentEvents && data.recentEvents) {
data.recentEvents = { ...data.recentEvents, locked: true };
}
return JSON.stringify(data, null, 2);
}
/**
* Apply locks to Characters tracker
* @param {Object} data - Parsed characters data
* @param {Object} lockedItems - Locked items configuration
* @returns {string} JSON string with locks applied
*/
function applyCharactersLocks(data, lockedItems) {
console.log('[Lock Manager] applyCharactersLocks called');
console.log('[Lock Manager] Locked items:', JSON.stringify(lockedItems, null, 2));
console.log('[Lock Manager] Input data:', JSON.stringify(data, null, 2));
// Handle both array format and object format
let characters = Array.isArray(data) ? data : (data.characters || []);
characters = characters.map((char, index) => {
const charName = char.name || char.characterName;
// Check if entire character is locked (index-based)
if (lockedItems[index] === true) {
console.log('[Lock Manager] Locking entire character by index:', index);
return { ...char, locked: true };
}
// Check if character name exists in locked items (could be nested object for field locks or boolean for full lock)
const charLocks = lockedItems[charName];
if (charLocks === true) {
// Entire character is locked
console.log('[Lock Manager] Locking entire character:', charName);
return { ...char, locked: true };
} else if (charLocks && typeof charLocks === 'object') {
// Character has field-level locks
const modifiedChar = { ...char };
for (const fieldName in charLocks) {
if (charLocks[fieldName] === true) {
// Check both the original field name and snake_case version
// (AI returns snake_case, but locks are stored with original configured names)
// Use the same conversion as toSnakeCase in thoughts.js
const snakeCaseFieldName = fieldName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
let locked = false;
// Check at root level first (backward compatibility)
if (modifiedChar[fieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to field:', `${charName}.${fieldName}`);
modifiedChar[fieldName] = {
value: modifiedChar[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar[snakeCaseFieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to snake_case field:', `${charName}.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar[snakeCaseFieldName] = {
value: modifiedChar[snakeCaseFieldName],
locked: true
};
locked = true;
}
// Check in nested objects (details, relationship, thoughts)
if (!locked && modifiedChar.details) {
if (modifiedChar.details[fieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to details field:', `${charName}.details.${fieldName}`);
if (!modifiedChar.details || typeof modifiedChar.details !== 'object') {
modifiedChar.details = {};
} else {
modifiedChar.details = { ...modifiedChar.details };
}
modifiedChar.details[fieldName] = {
value: modifiedChar.details[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.details[snakeCaseFieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to details snake_case field:', `${charName}.details.${snakeCaseFieldName} (from ${fieldName})`);
if (!modifiedChar.details || typeof modifiedChar.details !== 'object') {
modifiedChar.details = {};
} else {
modifiedChar.details = { ...modifiedChar.details };
}
modifiedChar.details[snakeCaseFieldName] = {
value: modifiedChar.details[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
// Check in relationship object
if (!locked && modifiedChar.relationship) {
if (modifiedChar.relationship[fieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to relationship field:', `${charName}.relationship.${fieldName}`);
modifiedChar.relationship = { ...modifiedChar.relationship };
modifiedChar.relationship[fieldName] = {
value: modifiedChar.relationship[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.relationship[snakeCaseFieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to relationship snake_case field:', `${charName}.relationship.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar.relationship = { ...modifiedChar.relationship };
modifiedChar.relationship[snakeCaseFieldName] = {
value: modifiedChar.relationship[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
// Check in thoughts object
if (!locked && modifiedChar.thoughts) {
if (modifiedChar.thoughts[fieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to thoughts field:', `${charName}.thoughts.${fieldName}`);
modifiedChar.thoughts = { ...modifiedChar.thoughts };
modifiedChar.thoughts[fieldName] = {
value: modifiedChar.thoughts[fieldName],
locked: true
};
locked = true;
} else if (modifiedChar.thoughts[snakeCaseFieldName] !== undefined) {
console.log('[Lock Manager] Applying lock to thoughts snake_case field:', `${charName}.thoughts.${snakeCaseFieldName} (from ${fieldName})`);
modifiedChar.thoughts = { ...modifiedChar.thoughts };
modifiedChar.thoughts[snakeCaseFieldName] = {
value: modifiedChar.thoughts[snakeCaseFieldName],
locked: true
};
locked = true;
}
}
}
}
return modifiedChar;
}
// No locks for this character
return char;
});
const result = Array.isArray(data)
? JSON.stringify(characters, null, 2)
: JSON.stringify({ ...data, characters }, null, 2);
console.log('[Lock Manager] Output data:', result);
return result;
}
/**
* Remove locks from tracker data received from AI.
* Strips "locked": true from all items to clean up the data.
*
* @param {string} trackerData - JSON string of tracker data
* @returns {string} Tracker data with locks removed
*/
export function removeLocks(trackerData) {
if (!trackerData) return trackerData;
// Try to parse as JSON
const parsed = repairJSON(trackerData);
if (!parsed) {
// Not JSON format, return as-is
return trackerData;
}
// Recursively remove all "locked" properties
const cleaned = removeLockedProperties(parsed);
return JSON.stringify(cleaned, null, 2);
}
/**
* Recursively remove "locked" properties from an object
* @param {*} obj - Object to clean
* @returns {*} Object with locked properties removed
*/
function removeLockedProperties(obj) {
if (Array.isArray(obj)) {
return obj.map(item => removeLockedProperties(item));
} else if (obj !== null && typeof obj === 'object') {
const cleaned = {};
for (const key in obj) {
if (key !== 'locked') {
cleaned[key] = removeLockedProperties(obj[key]);
}
}
return cleaned;
}
return obj;
}
/**
* Check if a specific item is locked
* @param {string} trackerType - Type of tracker
* @param {string} itemPath - Path to the item (e.g., 'stats.Health', 'quests.main.0')
* @returns {boolean} Whether the item is locked
*/
export function isItemLocked(trackerType, itemPath) {
const lockedItems = extensionSettings.lockedItems?.[trackerType];
if (!lockedItems) return false;
const parts = itemPath.split('.');
let current = lockedItems;
for (const part of parts) {
if (current[part] === undefined) return false;
current = current[part];
}
return !!current;
}
/**
* Toggle lock state for a specific item
* @param {string} trackerType - Type of tracker
* @param {string} itemPath - Path to the item
* @param {boolean} locked - New lock state
*/
export function setItemLock(trackerType, itemPath, locked) {
console.log('[Lock Manager] setItemLock called:', { trackerType, itemPath, locked });
if (!extensionSettings.lockedItems) {
extensionSettings.lockedItems = {};
}
if (!extensionSettings.lockedItems[trackerType]) {
extensionSettings.lockedItems[trackerType] = {};
}
const parts = itemPath.split('.');
let current = extensionSettings.lockedItems[trackerType];
// Navigate to parent of target
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
// Set or remove lock
const finalKey = parts[parts.length - 1];
if (locked) {
current[finalKey] = true;
} else {
delete current[finalKey];
}
console.log('[Lock Manager] Locked items after set:', JSON.stringify(extensionSettings.lockedItems, null, 2));
}
+342 -18
View File
@@ -1,11 +1,13 @@
/** /**
* Parser Module * Parser Module
* Handles parsing of AI responses to extract tracker data * Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new v3 JSON format
*/ */
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js'; import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js'; import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js'; import { extractInventory } from './inventoryParser.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/** /**
* Helper to separate emoji from text in a string * Helper to separate emoji from text in a string
@@ -159,36 +161,246 @@ export function parseResponse(responseText) {
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, ''); cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars'); debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars');
// Check if response uses XML <trackers> tags (new format) // Remove "FORMAT:" markers that the model might accidentally output
cleanedResponse = cleanedResponse.replace(/FORMAT:\s*/gi, '');
debugLog('[RPG Parser] Removed FORMAT: markers, new length:', cleanedResponse.length + ' chars');
// First, try to extract raw JSON objects (v3 format)
// Note: Prompts now instruct models to use ```json``` code blocks, but we extract
// from any JSON found using brace-matching for maximum compatibility
// Use brace-matching to find complete JSON objects
const extractedObjects = [];
let i = 0;
while (i < cleanedResponse.length) {
if (cleanedResponse[i] === '{') {
// Found opening brace, find matching closing brace
let depth = 1;
let j = i + 1;
let inString = false;
let escapeNext = false;
while (j < cleanedResponse.length && depth > 0) {
const char = cleanedResponse[j];
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = !inString;
} else if (!inString) {
if (char === '{') depth++;
else if (char === '}') depth--;
}
j++;
}
if (depth === 0) {
// Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim();
extractedObjects.push(jsonContent);
i = j;
} else {
i++;
}
} else {
i++;
}
}
if (extractedObjects.length > 0) {
console.log(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
debugLog(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
// First, try to parse as unified JSON structure (new v3.1 format)
if (extractedObjects.length === 1) {
const parsed = repairJSON(extractedObjects[0]);
if (parsed && (parsed.userStats || parsed.infoBox || parsed.characters)) {
console.log('[RPG Parser] ✓ Detected unified JSON structure (v3.1 format)');
if (parsed.userStats) {
result.userStats = JSON.stringify(parsed.userStats);
console.log('[RPG Parser] ✓ Extracted userStats from unified structure');
}
if (parsed.infoBox) {
result.infoBox = JSON.stringify(parsed.infoBox);
console.log('[RPG Parser] ✓ Extracted infoBox from unified structure');
}
if (parsed.characters) {
result.characterThoughts = JSON.stringify(parsed.characters);
console.log('[RPG Parser] ✓ Extracted characters from unified structure');
}
if (result.userStats || result.infoBox || result.characterThoughts) {
console.log('[RPG Parser] ✓ Returning unified JSON parse results');
debugLog('[RPG Parser] Returning unified JSON parse results');
return result;
}
}
}
// Fall back to parsing multiple separate JSON objects (legacy v3.0 format)
for (let idx = 0; idx < extractedObjects.length; idx++) {
const jsonContent = extractedObjects[idx];
console.log(`[RPG Parser] Parsing object ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
console.log(`[RPG Parser] Full object ${idx + 1} length:`, jsonContent.length);
const parsed = repairJSON(jsonContent);
if (parsed) {
console.log(`[RPG Parser] Object ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Check if object is wrapped (e.g., {"userStats": {...}})
// Unwrap single-key objects that match our tracker types
let unwrapped = parsed;
if (Object.keys(parsed).length === 1) {
const key = Object.keys(parsed)[0];
if (key === 'userStats' || key === 'infoBox' || key === 'characters') {
unwrapped = parsed[key];
console.log(`[RPG Parser] ✓ Unwrapped ${key} object`);
}
}
// Detect tracker type by checking for top-level fields
if (unwrapped.stats || unwrapped.status || unwrapped.skills || unwrapped.inventory || unwrapped.quests) {
result.userStats = jsonContent;
console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted raw JSON User Stats');
} else if (unwrapped.date || unwrapped.location || unwrapped.weather || unwrapped.temperature || unwrapped.time) {
result.infoBox = jsonContent;
console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted raw JSON Info Box');
} else if (unwrapped.characters || Array.isArray(unwrapped)) {
result.characterThoughts = jsonContent;
console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted raw JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize object with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse raw JSON object', idx + 1);
}
}
if (result.userStats || result.infoBox || result.characterThoughts) {
console.log('[RPG Parser] ✓ Returning raw JSON parse results:', {
hasUserStats: !!result.userStats,
hasInfoBox: !!result.infoBox,
hasCharacters: !!result.characterThoughts
});
debugLog('[RPG Parser] Returning raw JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', extractedObjects.length, 'objects');
}
}
// Check for JSON code blocks (legacy v3 format with ```json fences)
// Look for ```json code blocks which indicate JSON format
const jsonBlockRegex = /```json\s*\n([\s\S]*?)```/g;
const jsonMatches = [...cleanedResponse.matchAll(jsonBlockRegex)];
if (jsonMatches.length > 0) {
console.log('[RPG Parser] ✓ Found', jsonMatches.length, 'JSON code blocks (v3 format with fences)');
debugLog('[RPG Parser] ✓ Found JSON code blocks (v3 format), parsing as JSON');
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent);
if (parsed) {
console.log(`[RPG Parser] JSON block ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Detect tracker type by checking for top-level fields
if (parsed.stats || parsed.status || parsed.skills || parsed.inventory || parsed.quests) {
result.userStats = jsonContent;
console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted JSON User Stats');
} else if (parsed.date || parsed.location || parsed.weather || parsed.temperature || parsed.time) {
result.infoBox = jsonContent;
console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted JSON Info Box');
} else if (parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize JSON block with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse JSON code block', idx + 1);
debugLog('[RPG Parser] ✗ Failed to parse JSON block, will try text fallback');
}
}
// If we found at least one valid JSON block, return the result
// Mixed formats (some JSON, some text) will still work
if (result.userStats || result.infoBox || result.characterThoughts) {
console.log('[RPG Parser] ✓ Returning JSON code block parse results:', {
hasUserStats: !!result.userStats,
hasInfoBox: !!result.infoBox,
hasCharacters: !!result.characterThoughts
});
debugLog('[RPG Parser] Returning JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', jsonMatches.length, 'JSON blocks');
}
}
// Check if response uses XML <trackers> tags (hybrid format)
const xmlMatch = cleanedResponse.match(/<trackers>([\s\S]*?)<\/trackers>/i); const xmlMatch = cleanedResponse.match(/<trackers>([\s\S]*?)<\/trackers>/i);
if (xmlMatch) { if (xmlMatch) {
debugLog('[RPG Parser] ✓ Found XML <trackers> tags, using XML parser'); debugLog('[RPG Parser] ✓ Found XML <trackers> tags, using XML parser');
const trackersContent = xmlMatch[1].trim(); const trackersContent = xmlMatch[1].trim();
// Extract sections from XML content (sections are not in code blocks) // Try to parse JSON blocks within XML first
const statsMatch = trackersContent.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i); const xmlJsonMatches = [...trackersContent.matchAll(jsonBlockRegex)];
if (statsMatch) { if (xmlJsonMatches.length > 0) {
result.userStats = stripBrackets(statsMatch[0].trim()); debugLog('[RPG Parser] Found JSON blocks within XML tags');
debugLog('[RPG Parser] ✓ Extracted Stats from XML'); for (const match of xmlJsonMatches) {
} const jsonContent = match[1].trim();
const parsed = repairJSON(jsonContent);
const infoBoxMatch = trackersContent.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i); if (parsed) {
if (infoBoxMatch) { if (parsed.type === 'userStats' || parsed.stats) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim()); result.userStats = jsonContent;
debugLog('[RPG Parser] ✓ Extracted Info Box from XML'); } else if (parsed.type === 'infoBox' || parsed.date || parsed.location) {
} result.infoBox = jsonContent;
} else if (parsed.type === 'characters' || parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
}
}
}
} else {
// Fallback to text extraction from XML content (legacy v2 text format)
const statsMatch = trackersContent.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
if (statsMatch) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from XML (text format)');
}
const charactersMatch = trackersContent.match(/Present Characters\s*\n\s*---[\s\S]*$/i); const infoBoxMatch = trackersContent.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
if (charactersMatch) { if (infoBoxMatch) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim()); result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from XML'); debugLog('[RPG Parser] ✓ Extracted Info Box from XML (text format)');
}
const charactersMatch = trackersContent.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
if (charactersMatch) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from XML (text format)');
}
} }
debugLog('[RPG Parser] Parsed from XML:', result); debugLog('[RPG Parser] Parsed from XML:', result);
return result; return result;
} }
// Fallback to markdown code block parsing (old format) // Fallback to markdown code block parsing (old text format or mixed format)
debugLog('[RPG Parser] No XML tags found, using code block parser'); debugLog('[RPG Parser] No XML tags found, using code block parser');
// Extract code blocks // Extract code blocks
@@ -289,7 +501,7 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] ======================================================='); debugLog('[RPG Parser] =======================================================');
return result; return result;
} } // End parseResponse
/** /**
* Parses user stats from the text and updates the extensionSettings. * Parses user stats from the text and updates the extensionSettings.
@@ -303,6 +515,118 @@ export function parseUserStats(statsText) {
debugLog('[RPG Parser] Stats text preview:', statsText.substring(0, 200)); debugLog('[RPG Parser] Stats text preview:', statsText.substring(0, 200));
try { try {
// Check if this is v3 JSON format - try to parse it first
let statsData = null;
const trimmed = statsText.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
statsData = repairJSON(statsText);
if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
// Extract stats from v3 JSON structure
if (statsData.stats && Array.isArray(statsData.stats)) {
console.log('[RPG Parser] ✓ Extracting stats array, count:', statsData.stats.length);
statsData.stats.forEach(stat => {
if (stat.id && typeof stat.value !== 'undefined') {
extensionSettings.userStats[stat.id] = stat.value;
console.log(`[RPG Parser] ✓ Set ${stat.id} = ${stat.value}`);
}
});
}
// Extract status
if (statsData.status) {
console.log('[RPG Parser] ✓ Extracting status:', statsData.status);
if (statsData.status.mood) {
extensionSettings.userStats.mood = statsData.status.mood;
console.log('[RPG Parser] ✓ Set mood =', statsData.status.mood);
}
if (statsData.status.conditions) {
extensionSettings.userStats.conditions = statsData.status.conditions;
console.log('[RPG Parser] ✓ Set conditions =', statsData.status.conditions);
}
}
// Extract inventory (convert v3 array format to v2 string format)
if (statsData.inventory) {
const inv = statsData.inventory;
// Convert arrays of {name, quantity} objects to comma-separated strings
const convertItems = (items) => {
if (!items || !Array.isArray(items)) return '';
return items.map(item => {
if (typeof item === 'object' && item.name) {
// Include quantity if > 1
return item.quantity && item.quantity > 1
? `${item.quantity}x ${item.name}`
: item.name;
}
return String(item);
}).join(', ');
};
// Convert stored object {location: [items]} to {location: "item1, item2"}
const convertStoredInventory = (stored) => {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {};
const result = {};
for (const [location, items] of Object.entries(stored)) {
if (Array.isArray(items)) {
result[location] = convertItems(items);
} else if (typeof items === 'string') {
result[location] = items;
} else {
result[location] = '';
}
}
return result;
};
extensionSettings.userStats.inventory = {
onPerson: convertItems(inv.onPerson),
clothing: convertItems(inv.clothing),
stored: convertStoredInventory(inv.stored),
assets: convertItems(inv.assets)
};
console.log('[RPG Parser] ✓ Converted v3 inventory:', extensionSettings.userStats.inventory);
}
// Extract quests (convert v3 object format to v2 string format)
if (statsData.quests) {
// Convert quest objects to strings
const convertQuest = (quest) => {
if (!quest) return '';
if (typeof quest === 'string') return quest;
if (typeof quest === 'object') {
// v3 format: {title, description, status}
return quest.title || quest.description || JSON.stringify(quest);
}
return String(quest);
};
extensionSettings.quests = {
main: convertQuest(statsData.quests.main),
optional: Array.isArray(statsData.quests.optional)
? statsData.quests.optional.map(convertQuest)
: []
};
console.log('[RPG Parser] ✓ Converted v3 quests:', extensionSettings.quests);
}
// Extract skills if present (store as object, not JSON string)
if (statsData.skills && Array.isArray(statsData.skills)) {
extensionSettings.userStats.skills = statsData.skills;
console.log('[RPG Parser] ✓ Set skills:', extensionSettings.userStats.skills);
}
debugLog('[RPG Parser] ✓ Successfully extracted v3 JSON data');
saveSettings();
return; // Done processing v3 format
}
}
// Fall back to v2 text format parsing if JSON parsing failed
debugLog('[RPG Parser] Falling back to v2 text format parsing');
// Get custom stat configuration // Get custom stat configuration
const trackerConfig = extensionSettings.trackerConfig; const trackerConfig = extensionSettings.trackerConfig;
const customStats = trackerConfig?.userStats?.customStats || []; const customStats = trackerConfig?.userStats?.customStats || [];
+481 -233
View File
@@ -7,6 +7,13 @@ import { getContext } from '../../../../../../extensions.js';
import { chat, getCurrentChatDetails, characters, this_chid } from '../../../../../../../script.js'; import { chat, getCurrentChatDetails, characters, this_chid } from '../../../../../../../script.js';
import { selected_group, getGroupMembers, getGroupChat, groups } from '../../../../../../group-chats.js'; import { selected_group, getGroupMembers, getGroupChat, groups } from '../../../../../../group-chats.js';
import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js'; import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js';
import {
buildUserStatsJSONInstruction,
buildInfoBoxJSONInstruction,
buildCharactersJSONInstruction,
addLockInstruction
} from './jsonPromptHelpers.js';
import { applyLocks } from './lockManager.js';
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -16,16 +23,26 @@ import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../co
*/ */
export const DEFAULT_HTML_PROMPT = `If appropriate, include inline HTML, CSS, and JS segments whenever they enhance visual storytelling (e.g., for in-world screens, posters, books, letters, signs, crests, labels, etc.). Style them to match the setting's theme (e.g., fantasy, sci-fi), keep the text readable, and embed all assets directly (using inline SVGs only with no external scripts, libraries, or fonts). Use these elements freely and naturally within the narrative as characters would encounter them, including animations, 3D effects, pop-ups, dropdowns, websites, and so on. Do not wrap the HTML/CSS/JS in code fences!`; export const DEFAULT_HTML_PROMPT = `If appropriate, include inline HTML, CSS, and JS segments whenever they enhance visual storytelling (e.g., for in-world screens, posters, books, letters, signs, crests, labels, etc.). Style them to match the setting's theme (e.g., fantasy, sci-fi), keep the text readable, and embed all assets directly (using inline SVGs only with no external scripts, libraries, or fonts). Use these elements freely and naturally within the narrative as characters would encounter them, including animations, 3D effects, pop-ups, dropdowns, websites, and so on. Do not wrap the HTML/CSS/JS in code fences!`;
/**
* Default Dialogue Coloring prompt text
*/
export const DEFAULT_DIALOGUE_COLORING_PROMPT = `Wrap all character/NPC "dialogues" in unique <font color=######>tags</font>, exemplary: <font color=#abc123>"You're pretty good."</font> Assign a distinct color to each speaker and reuse it whenever they speak again.`;
/** /**
* Default Spotify music prompt text (customizable by users) * Default Spotify music prompt text (customizable by users)
*/ */
export const DEFAULT_SPOTIFY_PROMPT = `If appropriate for the current scene's mood and atmosphere, suggest a song that fits the ambiance. Choose music that enhances the emotional tone, setting, or action of the scene.`; export const DEFAULT_SPOTIFY_PROMPT = `If fitting for the current scene's mood and atmosphere, suggest a song that fits the ambiance. Choose music that enhances the emotional tone, setting, or action of the scene.`;
/** /**
* Spotify format instruction (constant, not editable by users) * Spotify format instruction (constant, not editable by users)
*/ */
export const SPOTIFY_FORMAT_INSTRUCTION = `Include it in this exact format: <spotify:Song Title - Artist Name/>.`; export const SPOTIFY_FORMAT_INSTRUCTION = `Include it in this exact format: <spotify:Song Title - Artist Name/>.`;
/**
* Default Narrator Mode prompt text (customizable by users)
*/
export const DEFAULT_NARRATOR_PROMPT = `Infer the identity and details of characters present in each scene from the story context below. Do not use fixed character references; instead, identify characters naturally based on their actions, dialogue, and descriptions in the narrative.`;
/** /**
* Gets character card information for current chat (handles both single and group chats) * Gets character card information for current chat (handles both single and group chats)
* @returns {string} Formatted character information * @returns {string} Formatted character information
@@ -49,7 +66,10 @@ async function getCharacterCardsInfo() {
} }
characterInfo += `</narrator>\n\n`; characterInfo += `</narrator>\n\n`;
characterInfo += `Infer the identity and details of characters present in each scene from the story context below. Do not use fixed character references - instead, identify characters naturally based on their actions, dialogue, and descriptions in the narrative.\n\n`;
// Use custom narrator prompt if available, otherwise use default
const narratorPrompt = extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT;
characterInfo += narratorPrompt + '\n\n';
} }
return characterInfo; return characterInfo;
} }
@@ -192,8 +212,11 @@ function buildAttributesString() {
return `${attr.name} ${value}`; return `${attr.name} ${value}`;
}); });
// Add level at the end // Add level at the end (if enabled)
attributeParts.push(`LVL ${extensionSettings.level}`); const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false; // Default to true
if (showLevel) {
attributeParts.push(`LVL ${extensionSettings.level}`);
}
return attributeParts.join(', '); return attributeParts.join(', ');
} }
@@ -206,26 +229,70 @@ function buildAttributesString() {
*/ */
export function generateTrackerExample() { export function generateTrackerExample() {
let example = ''; let example = '';
const useXmlTags = extensionSettings.saveTrackerHistory;
// Use COMMITTED data for generation context, not displayed data // Use COMMITTED data for generation context, not displayed data
// Wrap each tracker section in markdown code blocks // Apply locks before sending to AI (for JSON format only)
// Build unified JSON structure with proper wrapper keys
const parts = [];
console.log('[RPG Companion] generateTrackerExample - enabled modules:', {
showUserStats: extensionSettings.showUserStats,
showInfoBox: extensionSettings.showInfoBox,
showCharacterThoughts: extensionSettings.showCharacterThoughts
});
console.log('[RPG Companion] generateTrackerExample - committed data:', {
hasUserStats: !!committedTrackerData.userStats,
hasInfoBox: !!committedTrackerData.infoBox,
hasCharacterThoughts: !!committedTrackerData.characterThoughts
});
if (extensionSettings.showUserStats && committedTrackerData.userStats) { if (extensionSettings.showUserStats && committedTrackerData.userStats) {
example += '```\n' + committedTrackerData.userStats + '\n```\n\n'; // Try to parse as JSON first, otherwise treat as text
try {
JSON.parse(committedTrackerData.userStats);
// It's valid JSON - apply locks
const lockedData = applyLocks(committedTrackerData.userStats, 'userStats');
parts.push(` "userStats": ${lockedData}`);
} catch {
// It's text format - no locks applied
example += '```\n' + committedTrackerData.userStats + '\n```\n';
}
} }
if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { if (extensionSettings.showInfoBox && committedTrackerData.infoBox) {
example += '```\n' + committedTrackerData.infoBox + '\n```\n\n'; try {
JSON.parse(committedTrackerData.infoBox);
const lockedData = applyLocks(committedTrackerData.infoBox, 'infoBox');
parts.push(` "infoBox": ${lockedData}`);
} catch {
example += '```\n' + committedTrackerData.infoBox + '\n```\n';
}
} }
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
example += '```\n' + committedTrackerData.characterThoughts + '\n```'; try {
JSON.parse(committedTrackerData.characterThoughts);
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
parts.push(` "characters": ${lockedData}`);
} catch {
example += '```\n' + committedTrackerData.characterThoughts + '\n```';
}
} }
// If we have JSON parts, wrap them in unified structure
if (parts.length > 0) {
example = '{\n' + parts.join(',\n') + '\n}';
}
console.log('[RPG Companion] generateTrackerExample - result length:', example.length, 'parts:', parts.length);
return example.trim(); return example.trim();
} }
/** /**
* Generates the instruction portion - format specifications and guidelines. * Generates the instruction portion - format specifications and guidelines.
* NOW USES JSON FORMAT (v3) instead of text format
* *
* @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt (true for main generation, false for separate tracker generation) * @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt (true for main generation, false for separate tracker generation)
* @param {boolean} includeContinuation - Whether to include "After updating the trackers, continue..." instruction * @param {boolean} includeContinuation - Whether to include "After updating the trackers, continue..." instruction
@@ -247,187 +314,77 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
const useXmlTags = extensionSettings.saveTrackerHistory; const useXmlTags = extensionSettings.saveTrackerHistory;
const openTag = useXmlTags ? '<trackers>\n' : ''; const openTag = useXmlTags ? '<trackers>\n' : '';
const closeTag = useXmlTags ? '\n</trackers>' : ''; const closeTag = useXmlTags ? '\n</trackers>' : '';
const codeBlockMarker = useXmlTags ? '' : '```'; const codeBlockMarker = '';
const endCodeBlockMarker = '';
// Universal instruction header // Universal instruction header
if (useXmlTags) { if (useXmlTags) {
// Format specification is always hardcoded instructions += `\nAt the start of every reply, you must attach an update to the trackers in EXACTLY the JSON format shown below, enclosed in <trackers></trackers> XML tags. `;
instructions += `\nAt the start of every reply, you must attach an update to the trackers in EXACTLY the same format as below, enclosed in <trackers></trackers> XML tags. `;
} else { } else {
// Format specification is always hardcoded instructions += '\nAt the start of every reply, you must attach an update to the trackers in EXACTLY the JSON format shown below as a single unified JSON object containing all enabled tracker fields. ';
instructions += `\nAt the start of every reply, you must attach an update to the trackers in EXACTLY the same format as below, enclosed in separate Markdown code fences. `;
} }
// Append custom instruction portion if available (same for both XML and Markdown) // Append custom instruction portion if available
const customPrompt = extensionSettings.customTrackerInstructionsPrompt; const customPrompt = extensionSettings.customTrackerInstructionsPrompt;
if (customPrompt) { if (customPrompt) {
instructions += customPrompt.replace(/{userName}/g, userName); instructions += customPrompt.replace(/{userName}/g, userName);
} else { } else {
instructions += `Replace X with actual numbers (e.g., 69) and replace all [placeholders] with concrete in-world details that ${userName} perceives about the current scene and the present characters. Do NOT keep the brackets or placeholder text in your response. For example: [Location] becomes Forest Clearing, [Mood Emoji] becomes 😊. `; instructions += `Replace X with actual numbers (e.g., 69) and replace all placeholders with concrete in-world details that ${userName} perceives about the current scene and the present characters. For example: "Location" becomes "Forest Clearing", "Mood Emoji" becomes "😊". `;
instructions += `Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user's actions, the passage of time, and logical consequences (0% if the time progressed only by a few minutes, 1-5% normally, and above 5% only if a major time-skip/event occurs).`; instructions += `Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user's actions, the passage of time, and logical consequences.`;
} }
// Add format specifications for each enabled tracker // Add lock instruction
instructions += addLockInstruction('');
// Add format specifications for each enabled tracker using JSON
// Wrap all trackers in a unified JSON structure
const enabledTrackers = [];
if (extensionSettings.showUserStats) { if (extensionSettings.showUserStats) {
const userStatsConfig = trackerConfig?.userStats; enabledTrackers.push('userStats');
const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || [];
instructions += codeBlockMarker + '\n';
instructions += `${userName}'s Stats\n`;
instructions += '---\n';
// Add custom stats dynamically
for (const stat of enabledStats) {
instructions += `- ${stat.name}: X%\n`;
}
// Add status section if enabled
if (userStatsConfig?.statusSection?.enabled) {
const statusFields = userStatsConfig.statusSection.customFields || [];
const statusFieldsText = statusFields.map(f => `${f}`).join(', ');
if (userStatsConfig.statusSection.showMoodEmoji) {
instructions += `Status: [Mood Emoji${statusFieldsText ? ', ' + statusFieldsText : ''}]\n`;
} else if (statusFieldsText) {
instructions += `Status: [${statusFieldsText}]\n`;
}
}
// Add skills section if enabled
if (userStatsConfig?.skillsSection?.enabled) {
const skillFields = userStatsConfig.skillsSection.customFields || [];
const skillFieldsText = skillFields.map(f => `[${f}]`).join(', ');
instructions += `Skills: [${skillFieldsText || 'Skill1, Skill2, etc.'}]\n`;
}
// Add inventory format based on feature flag - only if showInventory is enabled
if (extensionSettings.showInventory) {
if (FEATURE_FLAGS.useNewInventory) {
instructions += 'On Person: [Items currently carried/worn, or "None"]\n';
instructions += 'Clothing: [Clothing/Armor currently worn, or "None"]\n';
instructions += 'Stored - [Location Name]: [Items stored at this location]\n';
instructions += '(Add multiple "Stored - [Location]:" lines as needed for different storage locations)\n';
instructions += 'Assets: [Vehicles, property, major possessions, or "None"]\n';
} else {
// Legacy v1 format
instructions += 'Inventory: [Clothing/Armor, Inventory Items (list of important items, or "None")]\\n';
}
}
// Add quests section
instructions += 'Main Quests: [Short title of the currently active main quest (for example, "Save the world"), or "None"]\n';
instructions += 'Optional Quests: [Short titles of the currently active optional quests (for example, "Find Zandik\'s book"), or "None"]\n';
instructions += codeBlockMarker + '\n\n';
} }
if (extensionSettings.showInfoBox) { if (extensionSettings.showInfoBox) {
const infoBoxConfig = trackerConfig?.infoBox; enabledTrackers.push('infoBox');
const widgets = infoBoxConfig?.widgets || {}; }
if (extensionSettings.showCharacterThoughts) {
instructions += codeBlockMarker + '\n'; enabledTrackers.push('characters');
instructions += 'Info Box\n';
instructions += '---\n';
// Add only enabled widgets
if (widgets.date?.enabled) {
instructions += 'Date: [Weekday, Month, Year]\n';
}
if (widgets.weather?.enabled) {
instructions += 'Weather: [Weather Emoji, Forecast]\n';
}
if (widgets.temperature?.enabled) {
const unit = widgets.temperature.unit === 'F' ? '°F' : '°C';
instructions += `Temperature: [Temperature in ${unit}]\n`;
}
if (widgets.time?.enabled) {
instructions += 'Time: [Time Start → Time End]\n';
}
if (widgets.location?.enabled) {
instructions += 'Location: [Location]\n';
}
if (widgets.recentEvents?.enabled) {
instructions += 'Recent Events: [Up to three past events leading to the ongoing scene (short descriptors with no details, for example, "last-night date with Mary")]\n';
}
instructions += codeBlockMarker + '\n\n';
} }
if (extensionSettings.showCharacterThoughts) { if (enabledTrackers.length > 0) {
const presentCharsConfig = trackerConfig?.presentCharacters; instructions += '\n\nFORMAT:\n\nProvide EXACTLY ONE JSON code block with ALL tracker sections wrapped in a single object:\n\n```json\n{\n';
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
// Check if relationships are enabled if (extensionSettings.showUserStats) {
const relationshipsEnabled = presentCharsConfig?.relationships?.enabled !== false; // Default to true instructions += ' "userStats": ';
const relationshipFields = relationshipsEnabled ? (presentCharsConfig?.relationshipFields || []) : []; const userStatsJSON = buildUserStatsJSONInstruction();
// Add 2 spaces to all lines after the first to properly nest within root object
const thoughtsConfig = presentCharsConfig?.thoughts; instructions += userStatsJSON.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n');
const characterStats = presentCharsConfig?.characterStats; instructions += enabledTrackers.indexOf('userStats') < enabledTrackers.length - 1 ? ',\n' : '\n';
const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || [];
instructions += codeBlockMarker + '\n';
instructions += 'Present Characters\n';
instructions += '---\n';
// Build relationship placeholders (e.g., "Lover/Friend")
const relationshipPlaceholders = relationshipFields
.filter(r => r && r.trim())
.map(r => `${r}`)
.join('/');
// Build custom field placeholders (e.g., "[Appearance] | [Current Action]")
const fieldPlaceholders = enabledFields
.map(f => `[${f.name}]`)
.join(' | ');
// Character block format
if (extensionSettings.narratorMode) {
instructions += `- [Character Name (infer from story context; do not include ${userName}; state "Unavailable" if no other characters are present in the scene)]\n`;
} else {
instructions += `- [Name (do not include ${userName}; state "Unavailable" if no major characters are present in the scene)]\n`;
} }
// Details line with emoji and custom fields if (extensionSettings.showInfoBox) {
if (fieldPlaceholders) { instructions += ' "infoBox": ';
instructions += `Details: [Present Character's Emoji] | ${fieldPlaceholders}\n`; const infoBoxJSON = buildInfoBoxJSONInstruction();
} else { // Add 2 spaces to all lines after the first to properly nest within root object
instructions += `Details: [Present Character's Emoji]\n`; instructions += infoBoxJSON.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n');
instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n';
} }
// Relationship line (only if relationships are enabled) if (extensionSettings.showCharacterThoughts) {
if (relationshipsEnabled && relationshipPlaceholders) { instructions += ' "characters": ';
instructions += `Relationship: [(choose one: ${relationshipPlaceholders})]\n`; const charactersJSON = buildCharactersJSONInstruction();
// Add 2 spaces to all lines after the first to properly nest within root object
instructions += charactersJSON.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n');
} }
// Stats line (if enabled) instructions += '\n}\n```\n\nDo NOT output multiple separate JSON objects. Everything must be in ONE unified object with the keys shown above.';
if (enabledCharStats.length > 0) {
const statPlaceholders = enabledCharStats.map(s => `${s.name}: X%`).join(' | ');
instructions += `Stats: ${statPlaceholders}\n`;
}
// Thoughts line (if enabled)
if (thoughtsConfig?.enabled) {
const thoughtsName = thoughtsConfig.name || 'Thoughts';
const thoughtsDescription = thoughtsConfig.description || 'Internal monologue (in first person POV, up to three sentences long)';
instructions += `${thoughtsName}: [${thoughtsDescription}]\n`;
}
if (extensionSettings.narratorMode) {
instructions += `- … (Repeat the format above for every other character present in the scene, inferred from story context)\n`;
} else {
instructions += `- … (Repeat the format above for every other present major character)\n`;
}
instructions += codeBlockMarker + '\n\n';
} }
// Only add continuation instruction if includeContinuation is true // Only add continuation instruction if includeContinuation is true
if (includeContinuation) { if (includeContinuation) {
const customPrompt = extensionSettings.customTrackerContinuationPrompt; const customPrompt = extensionSettings.customTrackerContinuationPrompt;
if (customPrompt) { if (customPrompt) {
instructions += customPrompt + '\n\n'; instructions += '\n\n' + customPrompt + '\n\n';
} else { } else {
instructions += `After updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting the protagonist's performance, low hygiene influencing their social interactions, environmental factors shaping the scene, a character's emotional state coloring their responses, and so on. Remember, all bracketed placeholders (e.g., [Location], [Mood Emoji]) MUST be replaced with actual content without the square brackets.\n\n`; instructions += `\n\nAfter updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting the protagonist's performance, low hygiene influencing their social interactions, environmental factors shaping the scene, a character's emotional state coloring their responses, and so on. Remember, all bracketed placeholders (e.g., [Location], [Mood Emoji]) MUST be replaced with actual content without the square brackets.\n\n`;
} }
} }
@@ -482,6 +439,295 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
return instructions; return instructions;
} }
/**
* Formats tracker data as human-readable text for context injection.
* Converts JSON format to a concise, natural language summary.
* @param {string} jsonData - JSON formatted tracker data
* @param {string} trackerType - Type of tracker ('userStats', 'infoBox', 'characters')
* @param {string} userName - User's name for personalization
* @returns {string} Formatted text summary
*/
function formatTrackerDataForContext(jsonData, trackerType, userName) {
if (!jsonData) return '';
try {
const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;
let formatted = '';
// Helper to extract value from potentially locked fields and common object formats
const getValue = (field) => {
if (field === null || field === undefined) return '';
// If it's a locked object with {value, locked}, extract the value
if (field && typeof field === 'object' && !Array.isArray(field) && 'value' in field) {
return getValue(field.value); // Recursively handle in case value itself is locked
}
// If it's a regular value, return as string
if (typeof field !== 'object') {
return String(field);
}
// For arrays of strings, join them
if (Array.isArray(field)) {
return field.map(item => getValue(item)).filter(Boolean).join(', ');
}
// Handle common object formats
if (field && typeof field === 'object') {
// Status object: {mood, conditions}
if ('mood' in field && 'conditions' in field) {
const mood = getValue(field.mood);
const conditions = getValue(field.conditions);
return `${mood} - ${conditions}`;
}
// Skill/item/quest objects: {name}, {title}, {name, quantity}
if ('name' in field) {
const name = getValue(field.name);
if ('quantity' in field && field.quantity > 1) {
return `${name} (x${field.quantity})`;
}
return name;
}
if ('title' in field) {
return getValue(field.title);
}
// Time object: {start, end}
if ('start' in field && 'end' in field) {
return `${getValue(field.start)} - ${getValue(field.end)}`;
}
// Weather object: {emoji, forecast}
if ('emoji' in field && 'forecast' in field) {
return `${getValue(field.emoji)} ${getValue(field.forecast)}`;
}
// Generic object fallback: create key-value pairs
const keys = Object.keys(field);
if (keys.length > 0 && keys.length <= 3) {
const values = keys.map(k => {
const val = getValue(field[k]);
return val ? `${k}: ${val}` : null;
}).filter(Boolean);
if (values.length > 0) {
return values.join(', ');
}
}
}
return '';
};
if (trackerType === 'userStats') {
formatted += `${userName}'s Stats:\n`;
// Handle stats array format: [{id, name, value}, ...]
if (data.stats && Array.isArray(data.stats)) {
for (const stat of data.stats) {
if (stat && stat.value !== undefined) {
const statName = stat.name || (stat.id ? stat.id.charAt(0).toUpperCase() + stat.id.slice(1) : 'Unknown');
formatted += `${statName}: ${stat.value}\n`;
}
}
} else {
// Fallback: handle flat format {health: 10, mana: 20, ...}
const statFieldOrder = ['health', 'mana', 'stamina', 'satiety', 'hygiene', 'energy', 'arousal'];
const specialFields = ['status', 'mood', 'skills', 'inventory', 'quests'];
for (const statName of statFieldOrder) {
if (data[statName] !== undefined) {
const value = getValue(data[statName]);
if (value) {
const displayName = statName.charAt(0).toUpperCase() + statName.slice(1);
formatted += `${displayName}: ${value}\n`;
}
}
}
// Custom numeric stats
for (const [key, value] of Object.entries(data)) {
if (!statFieldOrder.includes(key) && !specialFields.includes(key) && typeof value === 'number') {
const displayName = key.charAt(0).toUpperCase() + key.slice(1);
formatted += `${displayName}: ${getValue(value)}\n`;
}
}
}
// Status/mood
if (data.status) formatted += `Status: ${getValue(data.status)}\n`;
if (data.mood) formatted += `Mood: ${getValue(data.mood)}\n`;
// Skills - handle both array and object format
if (data.skills) {
if (Array.isArray(data.skills)) {
// Array format: ["Combat", "Magic", "Stealth"]
const skillsList = data.skills.map(s => getValue(s)).filter(s => s).join(', ');
if (skillsList) formatted += `Skills: ${skillsList}\n`;
} else if (typeof data.skills === 'object') {
// Object format: {Combat: 50, Magic: 30}
const skillsList = Object.entries(data.skills)
.map(([name, val]) => {
const skillName = getValue(name);
const skillVal = getValue(val);
return skillVal ? `${skillName}: ${skillVal}` : skillName;
})
.filter(s => s)
.join(', ');
if (skillsList) formatted += `Skills: ${skillsList}\n`;
}
}
// Inventory sections
if (data.inventory) {
const inv = data.inventory;
if (inv.onPerson && Array.isArray(inv.onPerson) && inv.onPerson.length > 0) {
const items = inv.onPerson.map(i => getValue(i)).filter(i => i);
if (items.length > 0) formatted += `On Person: ${items.join(', ')}\n`;
}
if (inv.clothing && Array.isArray(inv.clothing) && inv.clothing.length > 0) {
const items = inv.clothing.map(i => getValue(i)).filter(i => i);
if (items.length > 0) formatted += `Clothing: ${items.join(', ')}\n`;
}
if (inv.stored && typeof inv.stored === 'object' && !Array.isArray(inv.stored)) {
for (const [location, items] of Object.entries(inv.stored)) {
if (Array.isArray(items) && items.length > 0) {
const itemsList = items.map(i => getValue(i)).filter(i => i);
if (itemsList.length > 0) {
formatted += `${getValue(location)}: ${itemsList.join(', ')}\n`;
}
}
}
}
if (inv.assets && Array.isArray(inv.assets) && inv.assets.length > 0) {
const items = inv.assets.map(i => getValue(i)).filter(i => i);
if (items.length > 0) formatted += `Assets: ${items.join(', ')}\n`;
}
}
// Quests
if (data.quests) {
const quests = data.quests;
// Main quest - handle string, array, or object with {title}
if (quests.main) {
if (typeof quests.main === 'string') {
const mainQuest = getValue(quests.main);
if (mainQuest) formatted += `Main Quest: ${mainQuest}\n`;
} else if (Array.isArray(quests.main) && quests.main.length > 0) {
const questsList = quests.main.map(q => getValue(q)).filter(q => q);
if (questsList.length > 0) formatted += `Main Quests: ${questsList.join(', ')}\n`;
} else if (typeof quests.main === 'object') {
// Handle {title: "..."} format
const mainQuest = getValue(quests.main);
if (mainQuest) formatted += `Main Quest: ${mainQuest}\n`;
}
}
// Optional quests
if (quests.optional && Array.isArray(quests.optional) && quests.optional.length > 0) {
const questsList = quests.optional.map(q => getValue(q)).filter(q => q);
if (questsList.length > 0) formatted += `Optional Quests: ${questsList.join(', ')}\n`;
}
}
} else if (trackerType === 'infoBox') {
formatted += `Info Box:\n`;
if (data.location) formatted += `Location: ${getValue(data.location)}\n`;
if (data.date) formatted += `Date: ${getValue(data.date)}\n`;
if (data.time) formatted += `Time: ${getValue(data.time)}\n`;
if (data.weather) formatted += `Weather: ${getValue(data.weather)}\n`;
if (data.temperature) formatted += `Temperature: ${getValue(data.temperature)}\n`;
// Custom fields
const knownFields = ['location', 'date', 'time', 'weather', 'temperature'];
for (const [key, value] of Object.entries(data)) {
if (!knownFields.includes(key)) {
const val = getValue(value);
if (val) {
// Convert camelCase to Title Case with spaces (recentEvents -> Recent Events)
const displayName = key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
formatted += `${displayName}: ${val}\n`;
}
}
}
} else if (trackerType === 'characters') {
if (Array.isArray(data)) {
formatted += `Present Characters:\n`;
for (const char of data) {
const charName = getValue(char.name) || 'Unknown';
formatted += `- ${charName}:\n`;
// Details section - parse all custom fields
if (char.details && typeof char.details === 'object') {
for (const [key, value] of Object.entries(char.details)) {
const fieldValue = getValue(value);
if (fieldValue) {
// Convert camelCase/snake_case to Title Case with spaces
const fieldName = key
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
formatted += ` ${fieldName}: ${fieldValue}\n`;
}
}
}
// Relationship
if (char.relationship) {
let relValue;
if (typeof char.relationship === 'object' && !Array.isArray(char.relationship) && 'status' in char.relationship) {
relValue = getValue(char.relationship.status);
} else {
relValue = getValue(char.relationship);
}
if (relValue) formatted += ` Relationship: ${relValue}\n`;
}
// Thoughts
if (char.thoughts) {
let thoughtValue;
if (typeof char.thoughts === 'object' && !Array.isArray(char.thoughts) && 'content' in char.thoughts) {
thoughtValue = getValue(char.thoughts.content);
} else {
thoughtValue = getValue(char.thoughts);
}
if (thoughtValue) formatted += ` Thoughts: ${thoughtValue}\n`;
}
// Stats
if (char.stats && typeof char.stats === 'object' && !Array.isArray(char.stats)) {
const statsList = Object.entries(char.stats)
.map(([name, val]) => {
const statValue = getValue(val);
return statValue ? `${name}: ${statValue}` : null;
})
.filter(s => s)
.join(', ');
if (statsList) formatted += ` Stats: ${statsList}\n`;
}
}
}
}
return formatted;
} catch (e) {
console.warn('[RPG Companion] Failed to format tracker data for context:', e);
console.warn('[RPG Companion] Error details:', e.stack);
return ''; // Return empty string on error to avoid breaking context
}
}
/** /**
* Generates a formatted contextual summary for SEPARATE mode injection. * Generates a formatted contextual summary for SEPARATE mode injection.
* Includes the full tracker data in original format (without code fences and separators). * Includes the full tracker data in original format (without code fences and separators).
@@ -495,41 +741,39 @@ export function generateContextualSummary() {
const trackerConfig = extensionSettings.trackerConfig; const trackerConfig = extensionSettings.trackerConfig;
let summary = ''; let summary = '';
// Helper function to clean tracker data (remove code fences and separator lines)
const cleanTrackerData = (data) => {
if (!data) return '';
return data
.split('\n')
.filter(line => {
const trimmed = line.trim();
return trimmed &&
!trimmed.startsWith('```') &&
trimmed !== '---';
})
.join('\n');
};
// Add User Stats tracker data if enabled // Add User Stats tracker data if enabled
if (extensionSettings.showUserStats && committedTrackerData.userStats) { if (extensionSettings.showUserStats && committedTrackerData.userStats) {
const cleanedStats = cleanTrackerData(committedTrackerData.userStats); try {
if (cleanedStats) { const formatted = formatTrackerDataForContext(committedTrackerData.userStats, 'userStats', userName);
summary += cleanedStats + '\n\n'; if (formatted) {
summary += formatted + '\n';
}
} catch (e) {
console.warn('[RPG Companion] Failed to format userStats for context:', e);
} }
} }
// Add Info Box tracker data if enabled // Add Info Box tracker data if enabled
if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { if (extensionSettings.showInfoBox && committedTrackerData.infoBox) {
const cleanedInfoBox = cleanTrackerData(committedTrackerData.infoBox); try {
if (cleanedInfoBox) { const formatted = formatTrackerDataForContext(committedTrackerData.infoBox, 'infoBox', userName);
summary += cleanedInfoBox + '\n\n'; if (formatted) {
summary += formatted + '\n';
}
} catch (e) {
console.warn('[RPG Companion] Failed to format infoBox for context:', e);
} }
} }
// Add Present Characters tracker data if enabled // Add Present Characters tracker data if enabled
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
const cleanedThoughts = cleanTrackerData(committedTrackerData.characterThoughts); try {
if (cleanedThoughts) { const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName);
summary += cleanedThoughts + '\n\n'; if (formatted) {
summary += formatted + '\n';
}
} catch (e) {
console.warn('[RPG Companion] Failed to format characters for context:', e);
} }
} }
@@ -568,47 +812,58 @@ export function generateRPGPromptText() {
promptText += `Here are the previous trackers in the roleplay that you should consider when responding:\n`; promptText += `Here are the previous trackers in the roleplay that you should consider when responding:\n`;
promptText += `<previous>\n`; promptText += `<previous>\n`;
if (extensionSettings.showUserStats) { // Build unified JSON structure for previous trackers (v3.1 format)
if (committedTrackerData.userStats) { const hasAnyPreviousData = committedTrackerData.userStats || committedTrackerData.infoBox || committedTrackerData.characterThoughts;
promptText += `Last ${userName}'s Stats:\n${committedTrackerData.userStats}\n\n`;
} else {
promptText += `Last ${userName}'s Stats:\nNone - this is the first update.\n\n`;
}
// Add current quests to the previous data context if (hasAnyPreviousData) {
if (extensionSettings.quests) { const unifiedPrevious = {};
if (extensionSettings.quests.main && extensionSettings.quests.main !== 'None') {
promptText += `Main Quests: ${extensionSettings.quests.main}\n`; if (extensionSettings.showUserStats && committedTrackerData.userStats) {
try {
// Try to parse as JSON - apply locks before adding to previous
const lockedData = applyLocks(committedTrackerData.userStats, 'userStats');
const parsed = JSON.parse(lockedData);
unifiedPrevious.userStats = parsed;
} catch {
// Old text format - show it separately for backward compat
promptText += `${committedTrackerData.userStats}\n\n`;
} }
if (extensionSettings.quests.optional && extensionSettings.quests.optional.length > 0) { }
const optionalQuests = extensionSettings.quests.optional.filter(q => q && q !== 'None').join(', ');
promptText += `Optional Quests: ${optionalQuests || 'None'}\n`; if (extensionSettings.showInfoBox && committedTrackerData.infoBox) {
try {
// Try to parse as JSON - apply locks before adding to previous
const lockedData = applyLocks(committedTrackerData.infoBox, 'infoBox');
const parsed = JSON.parse(lockedData);
unifiedPrevious.infoBox = parsed;
} catch {
// Old text format - show it separately for backward compat
if (!unifiedPrevious.userStats) {
promptText += `${committedTrackerData.infoBox}\n\n`;
}
} }
promptText += `\n`;
} }
// Add current skills to the previous data context if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
const skillsSection = extensionSettings.trackerConfig?.userStats?.skillsSection; try {
if (skillsSection?.enabled && skillsSection.customFields && skillsSection.customFields.length > 0) { // Try to parse as JSON - apply locks before adding to previous
const skillsList = skillsSection.customFields.join(', '); const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
promptText += `Skills: ${skillsList}\n\n`; const parsed = JSON.parse(lockedData);
unifiedPrevious.characters = parsed;
} catch {
// Old text format - show it separately for backward compat
if (!unifiedPrevious.userStats && !unifiedPrevious.infoBox) {
promptText += `${committedTrackerData.characterThoughts}\n`;
}
}
} }
}
if (extensionSettings.showInfoBox) { // If we successfully built a unified structure, display it
if (committedTrackerData.infoBox) { if (Object.keys(unifiedPrevious).length > 0) {
promptText += `Last Info Box:\n${committedTrackerData.infoBox}\n\n`; promptText += JSON.stringify(unifiedPrevious, null, 2) + '\n';
} else {
promptText += `Last Info Box:\nNone - this is the first update.\n\n`;
}
}
if (extensionSettings.showCharacterThoughts) {
if (committedTrackerData.characterThoughts) {
promptText += `Last Present Characters:\n${committedTrackerData.characterThoughts}\n`;
} else {
promptText += `Last Present Characters:\nNone - this is the first update.\n`;
} }
} else {
promptText += `None - this is the first update.\n`;
} }
promptText += `</previous>\n`; promptText += `</previous>\n`;
@@ -638,12 +893,12 @@ export async function generateSeparateUpdatePrompt() {
// Add character card information // Add character card information
const characterInfo = await getCharacterCardsInfo(); const characterInfo = await getCharacterCardsInfo();
if (characterInfo) { if (characterInfo) {
systemMessage += characterInfo + '\n\n'; systemMessage += characterInfo;
} }
systemMessage += `Here is the description of the protagonist for reference:\n`; systemMessage += `Here is the description of the protagonist for reference:\n`;
systemMessage += `<protagonist>\n{{persona}}\n</protagonist>\n`; systemMessage += `<protagonist>\n{{persona}}\n</protagonist>\n`;
systemMessage += `\n\n`; systemMessage += `\n`;
systemMessage += `Here are the last few messages in the conversation history (between the user and the roleplayer assistant) you should reference when responding:\n<history>`; systemMessage += `Here are the last few messages in the conversation history (between the user and the roleplayer assistant) you should reference when responding:\n<history>`;
messages.push({ messages.push({
@@ -665,7 +920,7 @@ export async function generateSeparateUpdatePrompt() {
// Build the instruction message // Build the instruction message
let instructionMessage = `</history>\n\n`; let instructionMessage = `</history>\n\n`;
instructionMessage += generateRPGPromptText().replace('start your response with', 'respond with'); instructionMessage += generateRPGPromptText().replace('start your response with', 'respond with');
instructionMessage += `Provide ONLY the requested data in the exact formats specified above. Do not include any roleplay response, other text, or commentary. Remember, all bracketed placeholders (e.g., [Location], [Mood Emoji]) MUST be replaced with actual content without the square brackets.`; instructionMessage += `\nProvide ONLY the requested data in the exact JSON format specified above. Do not include any roleplay response, other text, or commentary. Remember, all placeholders MUST be replaced with actual content. Do NOT wrap the JSON in code fences (\`\`\`json). Output the JSON object directly.`;
messages.push({ messages.push({
role: 'user', role: 'user',
@@ -678,23 +933,16 @@ export async function generateSeparateUpdatePrompt() {
/** /**
* Default custom instruction for avatar prompt generation * Default custom instruction for avatar prompt generation
*/ */
const DEFAULT_AVATAR_CUSTOM_INSTRUCTION = `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons, but your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable. const DEFAULT_AVATAR_CUSTOM_INSTRUCTION = `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons; however, your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable.
Your workflow strictly follows a logical sequence: Your workflow strictly follows a logical sequence:
First, establish the subject. If the character is from a known Intellectual Property (IP), franchise, anime, game, or movie, you MUST begin the prompt with their full name and the series title (e.g., "Nami from One Piece", "Geralt of Rivia from The Witcher"). This is the single most important anchor for the image and must take precedence. If the character is original, clearly describe their core identity, race, and appearance.
First, **establish the subject**. If the character is from a known Intellectual Property (IP), franchise, anime, game, or movie, **you MUST begin the prompt with their full name and the series title** (e.g., "Nami from One Piece", "Geralt of Rivia from The Witcher"). This is the single most important anchor for the image and must take precedence. If the character is original, clearly describe their core identity, race, and appearance. Next, set the framing. This is an avatar portrait. Focus strictly on the character's face and upper shoulders (a bust shot or close-up). Ensure the face is the central focal point.
Then, integrate the setting. Describe the character within their current environment as provided in the context, but keep it as a background element. Incorporate the lighting, weather, and atmosphere to influence the character's appearance (e.g., shadows on the face, wet hair from rain).
Next, **set the framing**. This is an avatar portrait. Focus strictly on the character's face and upper shoulders (bust shot or close-up). Ensure the face is the central focal point. Next, detail the facial specifics. Describe the character's current expression, eye contact, and mood in great detail based on the scene context and their personality. Mention visible clothing only at the neckline/shoulders.
Finally, infuse with aesthetics. Define the artistic style, medium (e.g., digital art, oil painting), and visual tone (e.g., cinematic lighting, ethereal atmosphere).
Then, **integrate the setting**. Describe the character *within* their current environment as provided in the context, but keep it as a background element. Incorporate the lighting, weather, and atmosphere to influence the character's appearance (e.g., shadows on the face, wet hair from rain). Your final description must be objective and concrete, and the use of metaphors and emotional rhetoric is strictly prohibited. It must also not contain meta tags or drawing instructions such as "8K" or "masterpiece".
Output only the final, modified prompt; do not output anything else.`;
Next, **detail the facial specifics**. Describe the character's current expression, eye contact, and mood in high detail based on the scene context and their personality. Mention visible clothing only at the neckline/shoulders.
Finally, **infuse with aesthetics**. Define the artistic style, medium (e.g., digital art, oil painting), and visual tone (e.g., cinematic lighting, ethereal atmosphere).
Your final description must be objective and concrete, and the use of metaphors and emotional rhetoric is strictly prohibited. It must also not contain meta tags or drawing instructions such as "8K" or "masterpiece".
Output only the final, modified prompt; do not output anything else.`;
/** /**
* Generates the prompt for LLM-based avatar prompt generation * Generates the prompt for LLM-based avatar prompt generation
+62 -40
View File
@@ -4,7 +4,7 @@
*/ */
import { getContext } from '../../../../../../extensions.js'; import { getContext } from '../../../../../../extensions.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, updateMessageBlock } from '../../../../../../../script.js'; import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, saveChatDebounced } from '../../../../../../../script.js';
// Core modules // Core modules
import { import {
@@ -15,6 +15,7 @@ import {
isPlotProgression, isPlotProgression,
setLastActionWasSwipe, setLastActionWasSwipe,
setIsPlotProgression, setIsPlotProgression,
setIsGenerating,
updateLastGeneratedData, updateLastGeneratedData,
updateCommittedTrackerData, updateCommittedTrackerData,
$musicPlayerContainer $musicPlayerContainer
@@ -25,6 +26,8 @@ import { saveChatData, loadChatData } from '../../core/persistence.js';
import { parseResponse, parseUserStats } from '../generation/parser.js'; import { parseResponse, parseUserStats } from '../generation/parser.js';
import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js'; import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js';
import { updateRPGData } from '../generation/apiClient.js'; import { updateRPGData } from '../generation/apiClient.js';
import { removeLocks } from '../generation/lockManager.js';
import { onGenerationStarted } from '../generation/injector.js';
// Rendering // Rendering
import { renderUserStats } from '../rendering/userStats.js'; import { renderUserStats } from '../rendering/userStats.js';
@@ -80,27 +83,35 @@ export function commitTrackerData() {
/** /**
* Event handler for when the user sends a message. * Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe. * Sets the flag to indicate this is NOT a swipe.
* In separate mode with auto-update disabled, commits the displayed tracker data. * In together mode, commits displayed data (only for real messages, not streaming placeholders).
*/ */
export function onMessageSent() { export function onMessageSent() {
if (!extensionSettings.enabled) return; if (!extensionSettings.enabled) return;
// User sent a new message - NOT a swipe console.log('[RPG Companion] 🟢 EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe);
setLastActionWasSwipe(false);
// console.log('[RPG Companion] 🟢 EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe);
// In separate mode with auto-update disabled, commit displayed tracker when user sends a message // Check if this is a streaming placeholder message (content = "...")
// When streaming is on, ST sends a "..." placeholder before generation starts
const context = getContext();
const chat = context.chat;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
if (lastMessage && lastMessage.mes === '...') {
console.log('[RPG Companion] 🟢 Ignoring onMessageSent: streaming placeholder message');
return;
}
console.log('[RPG Companion] 🟢 EVENT: onMessageSent (after placeholder check)');
console.log('[RPG Companion] 🟢 NOTE: lastActionWasSwipe will be reset in onMessageReceived after generation completes');
// For separate mode with auto-update disabled, commit displayed tracker
if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) { if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) {
// Commit whatever is currently displayed in lastGeneratedData
if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) { if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) {
committedTrackerData.userStats = lastGeneratedData.userStats; committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox; committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// Save to chat metadata console.log('[RPG Companion] 💾 SEPARATE MODE: Committed displayed tracker (auto-update disabled)');
saveChatData();
// console.log('[RPG Companion] 💾 Committed displayed tracker on user message (auto-update disabled)');
} }
} }
} }
@@ -109,24 +120,41 @@ export function onMessageSent() {
* Event handler for when a message is generated. * Event handler for when a message is generated.
*/ */
export async function onMessageReceived(data) { export async function onMessageReceived(data) {
console.log('[RPG Companion] onMessageReceived called, lastActionWasSwipe:', lastActionWasSwipe);
if (!extensionSettings.enabled) { if (!extensionSettings.enabled) {
return; return;
} }
// Reset swipe flag after generation completes
// This ensures next user message (whether from original or swipe) triggers commit
setLastActionWasSwipe(false);
console.log('[RPG Companion] 🟢 Reset lastActionWasSwipe = false (generation completed)');
if (extensionSettings.generationMode === 'together') { if (extensionSettings.generationMode === 'together') {
// In together mode, parse the response to extract RPG data // In together mode, parse the response to extract RPG data
// The message should be in chat[chat.length - 1] // Commit happens in onMessageSent (when user sends message, before generation)
const lastMessage = chat[chat.length - 1]; const lastMessage = chat[chat.length - 1];
if (lastMessage && !lastMessage.is_user) { if (lastMessage && !lastMessage.is_user) {
const responseText = lastMessage.mes; const responseText = lastMessage.mes;
// console.log('[RPG Companion] Parsing together mode response:', responseText);
const parsedData = parseResponse(responseText); const parsedData = parseResponse(responseText);
// Remove locks from parsed data (JSON format only, text format is unaffected)
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
// Parse and store Spotify URL if feature is enabled // Parse and store Spotify URL if feature is enabled
parseAndStoreSpotifyUrl(responseText); parseAndStoreSpotifyUrl(responseText);
// console.log('[RPG Companion] Parsed data:', parsedData);
// Update stored data // Update display data with newly parsed response
console.log('[RPG Companion] 📝 TOGETHER MODE: Updating lastGeneratedData with parsed response');
if (parsedData.userStats) { if (parsedData.userStats) {
lastGeneratedData.userStats = parsedData.userStats; lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats); parseUserStats(parsedData.userStats);
@@ -155,17 +183,6 @@ export async function onMessageReceived(data) {
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId);
// If there's no committed data yet (first time generating), automatically commit
// BUT: Only commit if this is NOT a swipe (same logic as separate mode)
if (!lastActionWasSwipe && !committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedData.characterThoughts;
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
} else {
// console.log('[RPG Companion] Data will be committed when user replies');
}
// Remove the tracker code blocks from the visible message // Remove the tracker code blocks from the visible message
let cleanedMessage = responseText; let cleanedMessage = responseText;
@@ -296,11 +313,12 @@ export function onMessageSwiped(messageIndex) {
return; return;
} }
// console.log('[RPG Companion] Message swiped at index:', messageIndex); console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped at index:', messageIndex);
// Get the message that was swiped // Get the message that was swiped
const message = chat[messageIndex]; const message = chat[messageIndex];
if (!message || message.is_user) { if (!message || message.is_user) {
console.log('[RPG Companion] 🔵 Ignoring swipe - message is user or undefined');
return; return;
} }
@@ -316,21 +334,21 @@ export function onMessageSwiped(messageIndex) {
if (!isExistingSwipe) { if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation // This is a NEW swipe that will trigger generation
setLastActionWasSwipe(true); setLastActionWasSwipe(true);
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (NEW generation) - lastActionWasSwipe =', lastActionWasSwipe); console.log('[RPG Companion] 🔵 NEW swipe detected - Set lastActionWasSwipe = true');
} else { } else {
// This is navigating to an EXISTING swipe - don't change the flag // This is navigating to an EXISTING swipe - don't change the flag
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (existing swipe navigation) - lastActionWasSwipe unchanged =', lastActionWasSwipe); console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
} }
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId); // console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load RPG data for this swipe into lastGeneratedData (for display only) // Load RPG data for this swipe
// This updates what the user sees, but does NOT commit it // lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION
// Committed data will be updated when/if the user replies to this swipe // It's safe to load swipe data into lastGeneratedData - it won't be committed due to !lastActionWasSwipe check
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) { if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId]; const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Update display data // Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null; lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null; lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
@@ -340,15 +358,12 @@ export function onMessageSwiped(messageIndex) {
parseUserStats(swipeData.userStats); parseUserStats(swipeData.userStats);
} }
// console.log('[RPG Companion] Loaded RPG data for swipe', currentSwipeId, '(display only, NOT committed)'); console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
// console.log('[RPG Companion] committedTrackerData unchanged - will be updated if user replies to this swipe');
} else { } else {
// No data for this swipe - keep existing lastGeneratedData (don't clear it) console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
// This ensures the display remains consistent and data is available for next commit
// console.log('[RPG Companion] No RPG data for swipe', currentSwipeId, '- keeping existing lastGeneratedData');
} }
// Re-render the panels (display only - committedTrackerData unchanged) // Re-render the panels
renderUserStats(); renderUserStats();
renderInfoBox(); renderInfoBox();
renderThoughts(); renderThoughts();
@@ -401,6 +416,8 @@ export function clearExtensionPrompts() {
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false); setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
// Note: rpg-companion-plot is not cleared here since it's passed via quiet_prompt option // Note: rpg-companion-plot is not cleared here since it's passed via quiet_prompt option
// console.log('[RPG Companion] Cleared all extension prompts'); // console.log('[RPG Companion] Cleared all extension prompts');
@@ -411,6 +428,11 @@ export function clearExtensionPrompts() {
* Re-applies checkpoint if SillyTavern unhid messages * Re-applies checkpoint if SillyTavern unhid messages
*/ */
export async function onGenerationEnded() { export async function onGenerationEnded() {
console.log('[RPG Companion] 🏁 onGenerationEnded called');
// Note: isGenerating flag is cleared in onMessageReceived after parsing (together mode)
// or in apiClient.js after separate generation completes (separate mode)
// SillyTavern may auto-unhide messages when generation stops // SillyTavern may auto-unhide messages when generation stops
// Re-apply checkpoint if one exists // Re-apply checkpoint if one exists
await restoreCheckpointOnLoad(); await restoreCheckpointOnLoad();
+46 -5
View File
@@ -39,15 +39,56 @@ let openForms = {
/** /**
* Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include * Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include
* current inventory in text format. * current inventory.
* Maintains JSON format if current data is JSON, otherwise uses text format.
* This ensures manual edits are immediately visible to AI in next generation. * This ensures manual edits are immediately visible to AI in next generation.
*/ */
function updateLastGeneratedDataInventory() { function updateLastGeneratedDataInventory() {
// Rebuild the userStats text format using custom stat names // Check if current data is in JSON format
const statsText = buildUserStatsText(); const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update inventory in JSON
const stats = extensionSettings.userStats;
// Update BOTH lastGeneratedData AND committedTrackerData // Convert inventory back to v3 format (arrays of {name, quantity})
// This makes manual edits immediately visible to AI const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory.onPerson),
clothing: convertToV3Items(stats.inventory.clothing),
stored: stats.inventory.stored || {},
assets: convertToV3Items(stats.inventory.assets)
};
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const statsText = buildUserStatsText();
lastGeneratedData.userStats = statsText; lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText; committedTrackerData.userStats = statsText;
} }
+46 -6
View File
@@ -79,15 +79,58 @@ export function updateInventoryItem(field, index, newName, location) {
/** /**
* Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include * Updates lastGeneratedData.userStats AND committedTrackerData.userStats to include
* current inventory in text format. * current inventory.
* Maintains JSON format if current data is JSON, otherwise uses text format.
* This ensures manual edits are immediately visible to AI in next generation. * This ensures manual edits are immediately visible to AI in next generation.
* @private * @private
*/ */
function updateLastGeneratedDataInventory() { function updateLastGeneratedDataInventory() {
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update inventory in JSON
const stats = extensionSettings.userStats;
// Convert inventory back to v3 format (arrays of {name, quantity})
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\d+)x\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory.onPerson),
clothing: convertToV3Items(stats.inventory.clothing),
stored: stats.inventory.stored || {},
assets: convertToV3Items(stats.inventory.assets)
};
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const stats = extensionSettings.userStats; const stats = extensionSettings.userStats;
const inventorySummary = buildInventorySummary(stats.inventory); const inventorySummary = buildInventorySummary(stats.inventory);
// Rebuild the userStats text format
const statsText = const statsText =
`Health: ${stats.health}%\n` + `Health: ${stats.health}%\n` +
`Satiety: ${stats.satiety}%\n` + `Satiety: ${stats.satiety}%\n` +
@@ -96,9 +139,6 @@ function updateLastGeneratedDataInventory() {
`Arousal: ${stats.arousal}%\n` + `Arousal: ${stats.arousal}%\n` +
`${stats.mood}: ${stats.conditions}\n` + `${stats.mood}: ${stats.conditions}\n` +
`${inventorySummary}`; `${inventorySummary}`;
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText; lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText; committedTrackerData.userStats = statsText;
} }
+202 -30
View File
@@ -12,6 +12,25 @@ import {
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/** /**
* Helper to separate emoji from text in a string * Helper to separate emoji from text in a string
@@ -56,41 +75,36 @@ function separateEmojiFromText(str) {
* Includes event listeners for editable fields. * Includes event listeners for editable fields.
*/ */
export function renderInfoBox() { export function renderInfoBox() {
if (!extensionSettings.showInfoBox || !$infoBoxContainer) { console.log('[RPG InfoBox Render] ==================== RENDERING INFO BOX ====================');
return; console.log('[RPG InfoBox Render] showInfoBox setting:', extensionSettings.showInfoBox);
} console.log('[RPG InfoBox Render] Container exists:', !!$infoBoxContainer);
// Add updating class for animation if (!extensionSettings.showInfoBox || !$infoBoxContainer) {
if (extensionSettings.enableAnimations) { console.log('[RPG InfoBox Render] Exiting: showInfoBox or container is false');
$infoBoxContainer.addClass('rpg-content-updating'); return;
} }
// Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) // Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh)
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox; const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
console.log('[RPG InfoBox Render] infoBoxData length:', infoBoxData ? infoBoxData.length : 'null');
console.log('[RPG InfoBox Render] infoBoxData preview:', infoBoxData ? infoBoxData.substring(0, 200) : 'null');
// If no data yet, show placeholder // If no data yet, hide the container (e.g., after cache clear)
if (!infoBoxData) { if (!infoBoxData) {
const placeholderHtml = ` console.log('[RPG InfoBox Render] No data, hiding container');
<div class="rpg-dashboard rpg-dashboard-row-1"> $infoBoxContainer.empty().hide();
<div class="rpg-dashboard-widget rpg-placeholder-widget">
<div class="rpg-placeholder-text" data-i18n-key="infobox.noData.title">${i18n.getTranslation('infobox.noData.title')}</div>
<div class="rpg-placeholder-hint" data-i18n-key="infobox.noData.instruction">${i18n.getTranslation('infobox.noData.instruction')}</div>
</div>
</div>
`;
$infoBoxContainer.html(placeholderHtml);
if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
}
return; return;
} }
// Show container and add updating class for animation
$infoBoxContainer.show();
if (extensionSettings.enableAnimations) {
$infoBoxContainer.addClass('rpg-content-updating');
}
// console.log('[RPG Companion] renderInfoBox called with data:', infoBoxData); // console.log('[RPG Companion] renderInfoBox called with data:', infoBoxData);
// Parse the info box data let data = {
const lines = infoBoxData.split('\n');
// console.log('[RPG Companion] Info Box split into lines:', lines);
const data = {
date: '', date: '',
weekday: '', weekday: '',
month: '', month: '',
@@ -105,6 +119,45 @@ export function renderInfoBox() {
characters: [] characters: []
}; };
// Check if data is v3 JSON format
const trimmed = infoBoxData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
const jsonData = repairJSON(infoBoxData);
if (jsonData) {
// Extract from v3 JSON structure
data.weatherEmoji = jsonData.weather?.emoji || '';
data.weatherForecast = jsonData.weather?.forecast || '';
data.temperature = jsonData.temperature ? `${jsonData.temperature.value}°${jsonData.temperature.unit}` : '';
data.tempValue = jsonData.temperature?.value || 0;
data.timeStart = jsonData.time?.start || '';
data.timeEnd = jsonData.time?.end || '';
data.location = jsonData.location?.value || '';
// Parse date string to extract weekday, month, year
if (jsonData.date?.value) {
data.date = jsonData.date.value;
// Expected format: "Tuesday, October 17th, 2023"
const dateParts = data.date.split(',').map(p => p.trim());
data.weekday = dateParts[0] || '';
data.month = dateParts[1] || '';
data.year = dateParts[2] || '';
}
// Skip to rendering
} else {
// JSON parsing failed, fall back to text parsing
parseTextFormat();
}
} else {
// Text format
parseTextFormat();
}
function parseTextFormat() {
// Parse the info box data
const lines = infoBoxData.split('\n');
// console.log('[RPG Companion] Info Box split into lines:', lines);
// Track which fields we've already parsed to avoid duplicates from mixed formats // Track which fields we've already parsed to avoid duplicates from mixed formats
const parsedFields = { const parsedFields = {
date: false, date: false,
@@ -270,6 +323,7 @@ export function renderInfoBox() {
// timeStart: data.timeStart, // timeStart: data.timeStart,
// location: data.location // location: data.location
// }); // });
}
// Get tracker configuration // Get tracker configuration
const config = extensionSettings.trackerConfig?.infoBox; const config = extensionSettings.trackerConfig?.infoBox;
@@ -303,8 +357,11 @@ export function renderInfoBox() {
weekdayDisplay = weekdayDisplay; weekdayDisplay = weekdayDisplay;
} }
const dateLockIconHtml = getLockIconHtml('infoBox', 'date');
row1Widgets.push(` row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-calendar-widget"> <div class="rpg-dashboard-widget rpg-calendar-widget">
${dateLockIconHtml}
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="Click to edit">${monthDisplay}</div> <div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="Click to edit">${monthDisplay}</div>
<div class="rpg-calendar-day rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}" title="Click to edit">${weekdayDisplay}</div> <div class="rpg-calendar-day rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}" title="Click to edit">${weekdayDisplay}</div>
<div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" data-full-value="${data.year || ''}" title="Click to edit">${yearDisplay}</div> <div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" data-full-value="${data.year || ''}" title="Click to edit">${yearDisplay}</div>
@@ -316,8 +373,11 @@ export function renderInfoBox() {
if (config?.widgets?.weather?.enabled) { if (config?.widgets?.weather?.enabled) {
const weatherEmoji = data.weatherEmoji || '🌤️'; const weatherEmoji = data.weatherEmoji || '🌤️';
const weatherForecast = data.weatherForecast || 'Weather'; const weatherForecast = data.weatherForecast || 'Weather';
const weatherLockIconHtml = getLockIconHtml('infoBox', 'weather');
row1Widgets.push(` row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-weather-widget"> <div class="rpg-dashboard-widget rpg-weather-widget">
${weatherLockIconHtml}
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="Click to edit emoji">${weatherEmoji}</div> <div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="Click to edit emoji">${weatherEmoji}</div>
<div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="Click to edit">${weatherForecast}</div> <div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="Click to edit">${weatherForecast}</div>
</div> </div>
@@ -357,8 +417,11 @@ export function renderInfoBox() {
const tempInCelsius = preferredUnit === 'F' ? Math.round((tempValue - 32) * 5/9) : tempValue; const tempInCelsius = preferredUnit === 'F' ? Math.round((tempValue - 32) * 5/9) : tempValue;
const tempPercent = Math.min(100, Math.max(0, ((tempInCelsius + 20) / 60) * 100)); const tempPercent = Math.min(100, Math.max(0, ((tempInCelsius + 20) / 60) * 100));
const tempColor = tempInCelsius < 10 ? '#4a90e2' : tempInCelsius < 25 ? '#67c23a' : '#e94560'; const tempColor = tempInCelsius < 10 ? '#4a90e2' : tempInCelsius < 25 ? '#67c23a' : '#e94560';
const tempLockIconHtml = getLockIconHtml('infoBox', 'temperature');
row1Widgets.push(` row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-temp-widget"> <div class="rpg-dashboard-widget rpg-temp-widget">
${tempLockIconHtml}
<div class="rpg-thermometer"> <div class="rpg-thermometer">
<div class="rpg-thermometer-bulb"></div> <div class="rpg-thermometer-bulb"></div>
<div class="rpg-thermometer-tube"> <div class="rpg-thermometer-tube">
@@ -372,7 +435,12 @@ export function renderInfoBox() {
// Time widget - show if enabled // Time widget - show if enabled
if (config?.widgets?.time?.enabled) { if (config?.widgets?.time?.enabled) {
// Determine which time value to display and edit
const hasTimeEnd = Boolean(data.timeEnd);
const hasTimeStart = Boolean(data.timeStart);
const timeDisplay = data.timeEnd || data.timeStart || '12:00'; const timeDisplay = data.timeEnd || data.timeStart || '12:00';
const timeField = hasTimeEnd ? 'timeEnd' : 'timeStart';
// Parse time for clock hands // Parse time for clock hands
const timeMatch = timeDisplay.match(/(\d+):(\d+)/); const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
let hourAngle = 0; let hourAngle = 0;
@@ -383,8 +451,12 @@ export function renderInfoBox() {
hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
minuteAngle = minutes * 6; // 6° per minute minuteAngle = minutes * 6; // 6° per minute
} }
const timeLockIconHtml = getLockIconHtml('infoBox', 'time');
row1Widgets.push(` row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-clock-widget"> <div class="rpg-dashboard-widget rpg-clock-widget">
${timeLockIconHtml}
<div class="rpg-clock"> <div class="rpg-clock">
<div class="rpg-clock-face"> <div class="rpg-clock-face">
<div class="rpg-clock-hour" style="transform: rotate(${hourAngle}deg)"></div> <div class="rpg-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
@@ -392,7 +464,7 @@ export function renderInfoBox() {
<div class="rpg-clock-center"></div> <div class="rpg-clock-center"></div>
</div> </div>
</div> </div>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="Click to edit">${timeDisplay}</div> <div class="rpg-time-value rpg-editable" contenteditable="true" data-field="${timeField}" title="Click to edit">${timeDisplay}</div>
</div> </div>
`); `);
} }
@@ -407,9 +479,12 @@ export function renderInfoBox() {
// Row 2: Location widget (full width) - show if enabled // Row 2: Location widget (full width) - show if enabled
if (config?.widgets?.location?.enabled) { if (config?.widgets?.location?.enabled) {
const locationDisplay = data.location || 'Location'; const locationDisplay = data.location || 'Location';
const locationLockIconHtml = getLockIconHtml('infoBox', 'location');
html += ` html += `
<div class="rpg-dashboard rpg-dashboard-row-2"> <div class="rpg-dashboard rpg-dashboard-row-2">
<div class="rpg-dashboard-widget rpg-location-widget"> <div class="rpg-dashboard-widget rpg-location-widget">
${locationLockIconHtml}
<div class="rpg-map-bg"> <div class="rpg-map-bg">
<div class="rpg-map-marker">📍</div> <div class="rpg-map-marker">📍</div>
</div> </div>
@@ -421,14 +496,26 @@ export function renderInfoBox() {
// Row 3: Recent Events widget (notebook style) - show if enabled // Row 3: Recent Events widget (notebook style) - show if enabled
if (config?.widgets?.recentEvents?.enabled) { if (config?.widgets?.recentEvents?.enabled) {
// Parse Recent Events from infoBox string // Parse Recent Events from infoBox (supports both JSON and text formats)
let recentEvents = []; let recentEvents = [];
if (committedTrackerData.infoBox) { if (committedTrackerData.infoBox) {
const recentEventsLine = committedTrackerData.infoBox.split('\n').find(line => line.startsWith('Recent Events:')); // Try JSON format first
if (recentEventsLine) { try {
const eventsString = recentEventsLine.replace('Recent Events:', '').trim(); const parsed = typeof committedTrackerData.infoBox === 'string'
if (eventsString) { ? JSON.parse(committedTrackerData.infoBox)
recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e); : committedTrackerData.infoBox;
if (parsed && Array.isArray(parsed.recentEvents)) {
recentEvents = parsed.recentEvents;
}
} catch (e) {
// Fall back to old text format
const recentEventsLine = committedTrackerData.infoBox.split('\n').find(line => line.startsWith('Recent Events:'));
if (recentEventsLine) {
const eventsString = recentEventsLine.replace('Recent Events:', '').trim();
if (eventsString) {
recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e);
}
} }
} }
} }
@@ -440,9 +527,12 @@ export function renderInfoBox() {
validEvents.push('Click to add event'); validEvents.push('Click to add event');
} }
const eventsLockIconHtml = getLockIconHtml('infoBox', 'recentEvents');
html += ` html += `
<div class="rpg-dashboard rpg-dashboard-row-3"> <div class="rpg-dashboard rpg-dashboard-row-3">
<div class="rpg-dashboard-widget rpg-events-widget"> <div class="rpg-dashboard-widget rpg-events-widget">
${eventsLockIconHtml}
<div class="rpg-notebook-header"> <div class="rpg-notebook-header">
<div class="rpg-notebook-ring"></div> <div class="rpg-notebook-ring"></div>
<div class="rpg-notebook-ring"></div> <div class="rpg-notebook-ring"></div>
@@ -517,6 +607,30 @@ export function renderInfoBox() {
} }
}); });
// Add event handler for lock icons (support both click and touch)
$infoBoxContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $lockIcon = $(this);
const tracker = $lockIcon.data('tracker');
const path = $lockIcon.data('path');
// Import lockManager dynamically to avoid circular dependencies
import('../generation/lockManager.js').then(({ setItemLock, isItemLocked }) => {
const isLocked = isItemLocked(tracker, path);
const newLockState = !isLocked;
setItemLock(tracker, path, newLockState);
// Update icon
$lockIcon.text(newLockState ? '🔒' : '🔓');
$lockIcon.attr('title', newLockState ? 'Locked - AI cannot change this' : 'Unlocked - AI can change this');
$lockIcon.toggleClass('locked', newLockState);
// Save settings to persist lock state
saveSettings();
});
});
// Remove updating class after animation // Remove updating class after animation
if (extensionSettings.enableAnimations) { if (extensionSettings.enableAnimations) {
setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500);
@@ -541,6 +655,64 @@ export function updateInfoBoxField(field, value) {
lastGeneratedData.infoBox = 'Info Box\n---\n'; lastGeneratedData.infoBox = 'Info Box\n---\n';
} }
// Check if data is in v3 JSON format
const trimmed = lastGeneratedData.infoBox.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Handle v3 JSON format
const jsonData = repairJSON(lastGeneratedData.infoBox);
if (jsonData) {
// Update the appropriate field based on v3 structure
if (field === 'weatherEmoji') {
if (!jsonData.weather) jsonData.weather = {};
jsonData.weather.emoji = value;
} else if (field === 'weatherForecast') {
if (!jsonData.weather) jsonData.weather = {};
jsonData.weather.forecast = value;
} else if (field === 'temperature') {
// Parse temperature value and unit
const tempMatch = value.match(/(-?\d+)\s*°?\s*([CF]?)/i);
if (tempMatch) {
if (!jsonData.temperature) jsonData.temperature = {};
jsonData.temperature.value = parseInt(tempMatch[1]);
jsonData.temperature.unit = (tempMatch[2] || 'C').toUpperCase();
}
} else if (field === 'timeStart') {
if (!jsonData.time) jsonData.time = {};
jsonData.time.start = value;
} else if (field === 'timeEnd') {
if (!jsonData.time) jsonData.time = {};
jsonData.time.end = value;
} else if (field === 'location') {
if (!jsonData.location) jsonData.location = {};
jsonData.location.value = value;
} else if (field === 'weekday' || field === 'month' || field === 'year') {
// Update date components
if (!jsonData.date) jsonData.date = {};
let currentDate = jsonData.date.value || '';
const dateParts = currentDate.split(',').map(p => p.trim());
if (field === 'weekday') {
dateParts[0] = value;
} else if (field === 'month') {
dateParts[1] = value;
} else if (field === 'year') {
dateParts[2] = value;
}
jsonData.date.value = dateParts.filter(p => p).join(', ');
}
// Save back as JSON
lastGeneratedData.infoBox = JSON.stringify(jsonData, null, 2);
committedTrackerData.infoBox = lastGeneratedData.infoBox;
saveChatData();
renderInfoBox();
console.log('[RPG Companion] Updated info box field (v3 JSON):', { field, value });
return;
}
}
// Fall back to text format handling
// Reconstruct the Info Box text with updated field // Reconstruct the Info Box text with updated field
const lines = lastGeneratedData.infoBox.split('\n'); const lines = lastGeneratedData.infoBox.split('\n');
let dateLineFound = false; let dateLineFound = false;
+137 -73
View File
@@ -4,14 +4,32 @@
*/ */
import { extensionSettings, $inventoryContainer } from '../../core/state.js'; import { extensionSettings, $inventoryContainer } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { getInventoryRenderOptions, restoreFormStates } from '../interaction/inventoryActions.js'; import { getInventoryRenderOptions, restoreFormStates } from '../interaction/inventoryActions.js';
import { updateInventoryItem } from '../interaction/inventoryEdit.js'; import { updateInventoryItem } from '../interaction/inventoryEdit.js';
import { parseItems } from '../../utils/itemParser.js'; import { parseItems } from '../../utils/itemParser.js';
import { i18n } from '../../core/i18n.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js';
// Type imports // Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/** /**
* Converts a location name to a safe ID for use in HTML element IDs. * Converts a location name to a safe ID for use in HTML element IDs.
* Must match the logic used in inventoryActions.js. * Must match the logic used in inventoryActions.js.
@@ -31,17 +49,17 @@ export function getLocationId(locationName) {
export function renderInventorySubTabs(activeTab = 'onPerson') { export function renderInventorySubTabs(activeTab = 'onPerson') {
return ` return `
<div class="rpg-inventory-subtabs"> <div class="rpg-inventory-subtabs">
<button class="rpg-inventory-subtab ${activeTab === 'onPerson' ? 'active' : ''}" data-tab="onPerson" data-i18n-key="inventory.section.onPerson"> <button class="rpg-inventory-subtab ${activeTab === 'onPerson' ? 'active' : ''}" data-tab="onPerson">
${i18n.getTranslation('inventory.section.onPerson')} On Person
</button> </button>
<button class="rpg-inventory-subtab ${activeTab === 'clothing' ? 'active' : ''}" data-tab="clothing" data-i18n-key="inventory.section.clothing"> <button class="rpg-inventory-subtab ${activeTab === 'clothing' ? 'active' : ''}" data-tab="clothing">
${i18n.getTranslation('inventory.section.clothing')} Clothing
</button> </button>
<button class="rpg-inventory-subtab ${activeTab === 'stored' ? 'active' : ''}" data-tab="stored" data-i18n-key="inventory.section.stored"> <button class="rpg-inventory-subtab ${activeTab === 'stored' ? 'active' : ''}" data-tab="stored">
${i18n.getTranslation('inventory.section.stored')} Stored
</button> </button>
<button class="rpg-inventory-subtab ${activeTab === 'assets' ? 'active' : ''}" data-tab="assets" data-i18n-key="inventory.section.assets"> <button class="rpg-inventory-subtab ${activeTab === 'assets' ? 'active' : ''}" data-tab="assets">
${i18n.getTranslation('inventory.section.assets')} Assets
</button> </button>
</div> </div>
`; `;
@@ -58,28 +76,34 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
let itemsHtml = ''; let itemsHtml = '';
if (items.length === 0) { if (items.length === 0) {
itemsHtml = `<div class="rpg-inventory-empty" data-i18n-key="inventory.onPerson.empty">${i18n.getTranslation('inventory.onPerson.empty')}</div>`; itemsHtml = '<div class="rpg-inventory-empty">No items carried</div>';
} else { } else {
if (viewMode === 'grid') { if (viewMode === 'grid') {
// Grid view: card-style items // Grid view: card-style items
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
return `
<div class="rpg-item-card" data-field="onPerson" data-index="${index}"> <div class="rpg-item-card" data-field="onPerson" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div> </div>
`).join(''); `}).join('');
} else { } else {
// List view: full-width rows // List view: full-width rows
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
return `
<div class="rpg-item-row" data-field="onPerson" data-index="${index}"> <div class="rpg-item-row" data-field="onPerson" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
</div> </div>
`).join(''); `}).join('');
} }
} }
@@ -88,30 +112,30 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
return ` return `
<div class="rpg-inventory-section" data-section="onPerson"> <div class="rpg-inventory-section" data-section="onPerson">
<div class="rpg-inventory-header"> <div class="rpg-inventory-header">
<h4 data-i18n-key="inventory.onPerson.title">${i18n.getTranslation('inventory.onPerson.title')}</h4> <h4>Items Currently Carried</h4>
<div class="rpg-inventory-header-actions"> <div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle"> <div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="list" title="${i18n.getTranslation('global.listView')}"> <button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="list" title="List view">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list"></i>
</button> </button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="grid" title="${i18n.getTranslation('global.gridView')}"> <button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="grid" title="Grid view">
<i class="fa-solid fa-th"></i> <i class="fa-solid fa-th"></i>
</button> </button>
</div> </div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson" title="Add new item"> <button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson" title="Add new item">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="inventory.onPerson.addItemButton">${i18n.getTranslation('inventory.onPerson.addItemButton')}</span> <i class="fa-solid fa-plus"></i> Add Item
</button> </button>
</div> </div>
</div> </div>
<div class="rpg-inventory-content"> <div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-onPerson" style="display: none;"> <div class="rpg-inline-form" id="rpg-add-item-form-onPerson" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-onPerson" placeholder="${i18n.getTranslation('inventory.onPerson.addItemPlaceholder')}" data-i18n-placeholder-key="inventory.onPerson.addItemPlaceholder" /> <input type="text" class="rpg-inline-input" id="rpg-new-item-onPerson" placeholder="Enter item name..." />
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="onPerson"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="onPerson">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="onPerson"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="onPerson">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-check"></i> Add
</button> </button>
</div> </div>
</div> </div>
@@ -134,28 +158,34 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
let itemsHtml = ''; let itemsHtml = '';
if (items.length === 0) { if (items.length === 0) {
itemsHtml = `<div class="rpg-inventory-empty" data-i18n-key="inventory.clothing.empty">${i18n.getTranslation('inventory.clothing.empty')}</div>`; itemsHtml = '<div class="rpg-inventory-empty">No clothing worn</div>';
} else { } else {
if (viewMode === 'grid') { if (viewMode === 'grid') {
// Grid view: card-style items // Grid view: card-style items
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
return `
<div class="rpg-item-card" data-field="clothing" data-index="${index}"> <div class="rpg-item-card" data-field="clothing" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div> </div>
`).join(''); `}).join('');
} else { } else {
// List view: full-width rows // List view: full-width rows
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
return `
<div class="rpg-item-row" data-field="clothing" data-index="${index}"> <div class="rpg-item-row" data-field="clothing" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
</div> </div>
`).join(''); `}).join('');
} }
} }
@@ -164,30 +194,30 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
return ` return `
<div class="rpg-inventory-section" data-section="clothing"> <div class="rpg-inventory-section" data-section="clothing">
<div class="rpg-inventory-header"> <div class="rpg-inventory-header">
<h4 data-i18n-key="inventory.clothing.title">${i18n.getTranslation('inventory.clothing.title')}</h4> <h4>Clothing Worn</h4>
<div class="rpg-inventory-header-actions"> <div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle"> <div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="list" title="${i18n.getTranslation('global.listView')}"> <button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="list" title="List view">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list"></i>
</button> </button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="grid" title="${i18n.getTranslation('global.gridView')}"> <button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="grid" title="Grid view">
<i class="fa-solid fa-th"></i> <i class="fa-solid fa-th"></i>
</button> </button>
</div> </div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="clothing" title="Add new item"> <button class="rpg-inventory-add-btn" data-action="add-item" data-field="clothing" title="Add new clothing item">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="inventory.clothing.addItemButton">${i18n.getTranslation('inventory.clothing.addItemButton')}</span> <i class="fa-solid fa-plus"></i> Add Clothing
</button> </button>
</div> </div>
</div> </div>
<div class="rpg-inventory-content"> <div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-clothing" style="display: none;"> <div class="rpg-inline-form" id="rpg-add-item-form-clothing" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-clothing" placeholder="${i18n.getTranslation('inventory.clothing.addItemPlaceholder')}" data-i18n-placeholder-key="inventory.clothing.addItemPlaceholder" /> <input type="text" class="rpg-inline-input" id="rpg-new-item-clothing" placeholder="Enter clothing item..." />
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="clothing"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="clothing">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="clothing"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="clothing">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-check"></i> Add
</button> </button>
</div> </div>
</div> </div>
@@ -212,30 +242,30 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
let html = ` let html = `
<div class="rpg-inventory-section" data-section="stored"> <div class="rpg-inventory-section" data-section="stored">
<div class="rpg-inventory-header"> <div class="rpg-inventory-header">
<h4 data-i18n-key="inventory.stored.title">${i18n.getTranslation('inventory.stored.title')}</h4> <h4>Storage Locations</h4>
<div class="rpg-inventory-header-actions"> <div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle"> <div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="list" title="${i18n.getTranslation('global.listView')}"> <button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="list" title="List view">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list"></i>
</button> </button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="grid" title="${i18n.getTranslation('global.gridView')}"> <button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="grid" title="Grid view">
<i class="fa-solid fa-th"></i> <i class="fa-solid fa-th"></i>
</button> </button>
</div> </div>
<button class="rpg-inventory-add-btn" data-action="add-location" title="Add new storage location"> <button class="rpg-inventory-add-btn" data-action="add-location" title="Add new storage location">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="inventory.stored.addLocationButton">${i18n.getTranslation('inventory.stored.addLocationButton')}</span> <i class="fa-solid fa-plus"></i> Add Location
</button> </button>
</div> </div>
</div> </div>
<div class="rpg-inventory-content"> <div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-location-form" style="display: none;"> <div class="rpg-inline-form" id="rpg-add-location-form" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-location-name" placeholder="${i18n.getTranslation('inventory.stored.addLocationPlaceholder')}" data-i18n-placeholder-key="inventory.stored.addLocationPlaceholder" /> <input type="text" class="rpg-inline-input" id="rpg-new-location-name" placeholder="Enter location name..." />
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-location"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-location">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-location"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-add-location">
<i class="fa-solid fa-check"></i> <span data-i18n-key="inventory.stored.saveButton">${i18n.getTranslation('inventory.stored.saveButton')}</span> <i class="fa-solid fa-check"></i> Save
</button> </button>
</div> </div>
</div> </div>
@@ -243,8 +273,8 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
if (locations.length === 0) { if (locations.length === 0) {
html += ` html += `
<div class="rpg-inventory-empty" data-i18n-key="inventory.stored.empty"> <div class="rpg-inventory-empty">
${i18n.getTranslation('inventory.stored.empty')} No storage locations yet. Click "Add Location" to create one.
</div> </div>
`; `;
} else { } else {
@@ -256,28 +286,34 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
let itemsHtml = ''; let itemsHtml = '';
if (items.length === 0) { if (items.length === 0) {
itemsHtml = `<div class="rpg-inventory-empty" data-i18n-key="inventory.stored.noItems">${i18n.getTranslation('inventory.stored.noItems')}</div>`; itemsHtml = '<div class="rpg-inventory-empty">No items stored here</div>';
} else { } else {
if (viewMode === 'grid') { if (viewMode === 'grid') {
// Grid view: card-style items // Grid view: card-style items
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
return `
<div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}"> <div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div> </div>
`).join(''); `}).join('');
} else { } else {
// List view: full-width rows // List view: full-width rows
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
return `
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}"> <div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item"> <button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
</div> </div>
`).join(''); `}).join('');
} }
} }
@@ -298,13 +334,13 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
</div> </div>
<div class="rpg-storage-content" ${isCollapsed ? 'style="display:none;"' : ''}> <div class="rpg-storage-content" ${isCollapsed ? 'style="display:none;"' : ''}>
<div class="rpg-inline-form" id="rpg-add-item-form-stored-${locationId}" style="display: none;"> <div class="rpg-inline-form" id="rpg-add-item-form-stored-${locationId}" style="display: none;">
<input type="text" class="rpg-inline-input rpg-location-item-input" data-location="${escapeHtml(location)}" placeholder="${i18n.getTranslation('inventory.stored.addItemToLocationPlaceholder')}" data-i18n-placeholder-key="inventory.stored.addItemToLocationPlaceholder" /> <input type="text" class="rpg-inline-input rpg-location-item-input" data-location="${escapeHtml(location)}" placeholder="Enter item name..." />
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="stored" data-location="${escapeHtml(location)}"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="stored" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="stored" data-location="${escapeHtml(location)}"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="stored" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-check"></i> Add
</button> </button>
</div> </div>
</div> </div>
@@ -313,18 +349,18 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
</div> </div>
<div class="rpg-storage-add-item-container"> <div class="rpg-storage-add-item-container">
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="Add item to this location"> <button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="Add item to this location">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="inventory.stored.addItemButton">${i18n.getTranslation('inventory.stored.addItemButton')}</span> <i class="fa-solid fa-plus"></i> Add Item
</button> </button>
</div> </div>
</div> </div>
<div class="rpg-inline-confirmation" id="rpg-remove-confirm-${locationId}" style="display: none;"> <div class="rpg-inline-confirmation" id="rpg-remove-confirm-${locationId}" style="display: none;">
<p>${i18n.getTranslation('inventory.stored.confirmRemoveLocationMessage', { location: escapeHtml(location) })}</p> <p>Remove "${escapeHtml(location)}"? This will delete all items stored there.</p>
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-remove-location" data-location="${escapeHtml(location)}"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-remove-location" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-confirm" data-action="confirm-remove-location" data-location="${escapeHtml(location)}"> <button class="rpg-inline-btn rpg-inline-confirm" data-action="confirm-remove-location" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-check"></i> <span data-i18n-key="inventory.stored.confirmRemoveLocationConfirmButton">${i18n.getTranslation('inventory.stored.confirmRemoveLocationConfirmButton')}</span> <i class="fa-solid fa-check"></i> Confirm
</button> </button>
</div> </div>
</div> </div>
@@ -352,28 +388,34 @@ export function renderAssetsView(assets, viewMode = 'list') {
let itemsHtml = ''; let itemsHtml = '';
if (items.length === 0) { if (items.length === 0) {
itemsHtml = `<div class="rpg-inventory-empty" data-i18n-key="inventory.assets.empty">${i18n.getTranslation('inventory.assets.empty')}</div>`; itemsHtml = '<div class="rpg-inventory-empty">No assets owned</div>';
} else { } else {
if (viewMode === 'grid') { if (viewMode === 'grid') {
// Grid view: card-style items // Grid view: card-style items
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
return `
<div class="rpg-item-card" data-field="assets" data-index="${index}"> <div class="rpg-item-card" data-field="assets" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset"> <button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
</div> </div>
`).join(''); `}).join('');
} else { } else {
// List view: full-width rows // List view: full-width rows
itemsHtml = items.map((item, index) => ` itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
return `
<div class="rpg-item-row" data-field="assets" data-index="${index}"> <div class="rpg-item-row" data-field="assets" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span> <span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="${i18n.getTranslation('inventory.assets.removeAssetTitle')}"> <button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<i class="fa-solid fa-times"></i> <i class="fa-solid fa-times"></i>
</button> </button>
</div> </div>
`).join(''); `}).join('');
} }
} }
@@ -382,30 +424,30 @@ export function renderAssetsView(assets, viewMode = 'list') {
return ` return `
<div class="rpg-inventory-section" data-section="assets"> <div class="rpg-inventory-section" data-section="assets">
<div class="rpg-inventory-header"> <div class="rpg-inventory-header">
<h4 data-i18n-key="inventory.assets.title">${i18n.getTranslation('inventory.assets.title')}</h4> <h4>Vehicles, Property & Major Possessions</h4>
<div class="rpg-inventory-header-actions"> <div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle"> <div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="list" title="${i18n.getTranslation('global.listView')}"> <button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="list" title="List view">
<i class="fa-solid fa-list"></i> <i class="fa-solid fa-list"></i>
</button> </button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="grid" title="${i18n.getTranslation('global.gridView')}"> <button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="grid" title="Grid view">
<i class="fa-solid fa-th"></i> <i class="fa-solid fa-th"></i>
</button> </button>
</div> </div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets" title="Add new asset"> <button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets" title="Add new asset">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="inventory.assets.addAssetButton">${i18n.getTranslation('inventory.assets.addAssetButton')}</span> <i class="fa-solid fa-plus"></i> Add Asset
</button> </button>
</div> </div>
</div> </div>
<div class="rpg-inventory-content"> <div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-assets" style="display: none;"> <div class="rpg-inline-form" id="rpg-add-item-form-assets" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-assets" placeholder="${i18n.getTranslation('inventory.assets.addAssetPlaceholder')}" data-i18n-placeholder-key="inventory.assets.addAssetPlaceholder" /> <input type="text" class="rpg-inline-input" id="rpg-new-item-assets" placeholder="Enter asset name..." />
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="assets"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="assets">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="assets"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="assets">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-check"></i> Add
</button> </button>
</div> </div>
</div> </div>
@@ -414,7 +456,8 @@ export function renderAssetsView(assets, viewMode = 'list') {
</div> </div>
<div class="rpg-inventory-hint"> <div class="rpg-inventory-hint">
<i class="fa-solid fa-info-circle"></i> <i class="fa-solid fa-info-circle"></i>
<span data-i18n-key="inventory.assets.description">${i18n.getTranslation('inventory.assets.description')}</span> Assets include vehicles (cars, motorcycles), property (homes, apartments),
and major equipment (workshop tools, special items).
</div> </div>
</div> </div>
</div> </div>
@@ -451,7 +494,6 @@ function generateInventoryHTML(inventory, options = {}) {
v2Inventory = { v2Inventory = {
version: 2, version: 2,
onPerson: 'None', onPerson: 'None',
clothing: 'None',
stored: {}, stored: {},
assets: 'None' assets: 'None'
}; };
@@ -461,9 +503,6 @@ function generateInventoryHTML(inventory, options = {}) {
if (!v2Inventory.onPerson || typeof v2Inventory.onPerson !== 'string') { if (!v2Inventory.onPerson || typeof v2Inventory.onPerson !== 'string') {
v2Inventory.onPerson = 'None'; v2Inventory.onPerson = 'None';
} }
if (!v2Inventory.clothing || typeof v2Inventory.clothing !== 'string') {
v2Inventory.clothing = 'None';
}
if (!v2Inventory.stored || typeof v2Inventory.stored !== 'object' || Array.isArray(v2Inventory.stored)) { if (!v2Inventory.stored || typeof v2Inventory.stored !== 'object' || Array.isArray(v2Inventory.stored)) {
v2Inventory.stored = {}; v2Inventory.stored = {};
} }
@@ -563,6 +602,31 @@ export function renderInventory() {
const newName = $(this).text().trim(); const newName = $(this).text().trim();
updateInventoryItem(field, index, newName, location); updateInventoryItem(field, index, newName, location);
}); });
// Add event listener for section lock icon clicks (support both click and touch)
$inventoryContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
} }
/** /**
+4 -4
View File
@@ -56,20 +56,20 @@ function openInSpotify(songData) {
* @param {HTMLElement} container - Container element to render into * @param {HTMLElement} container - Container element to render into
*/ */
export function renderMusicPlayer(container) { export function renderMusicPlayer(container) {
console.log('[RPG Companion] Music Player: renderMusicPlayer called'); // console.log('[RPG Companion] Music Player: renderMusicPlayer called');
// Remove old chat-attached player if it exists // Remove old chat-attached player if it exists
$('#rpg-chat-music-player').remove(); $('#rpg-chat-music-player').remove();
console.log('[RPG Companion] Music Player: enableSpotifyMusic =', extensionSettings.enableSpotifyMusic); // console.log('[RPG Companion] Music Player: enableSpotifyMusic =', extensionSettings.enableSpotifyMusic);
if (!extensionSettings.enableSpotifyMusic) { if (!extensionSettings.enableSpotifyMusic) {
console.warn('[RPG Companion] Music Player: Spotify music is disabled'); // console.warn('[RPG Companion] Music Player: Spotify music is disabled');
return; return;
} }
const songData = committedTrackerData.spotifyUrl; const songData = committedTrackerData.spotifyUrl;
console.log('[RPG Companion] Music Player: Rendering with song:', songData); // console.log('[RPG Companion] Music Player: Rendering with song:', songData);
if (!songData || !songData.displayText) { if (!songData || !songData.displayText) {
// No song - don't show anything // No song - don't show anything
+71 -26
View File
@@ -5,7 +5,24 @@
import { extensionSettings, $questsContainer } from '../../core/state.js'; import { extensionSettings, $questsContainer } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js'; import { saveSettings } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js'; import { isItemLocked, setItemLock } from '../generation/lockManager.js';
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
* @param {string} path - Item path
* @returns {string} Lock icon HTML or empty string
*/
function getLockIconHtml(tracker, path) {
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (!showLockIcons) return '';
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
/** /**
* HTML escape helper * HTML escape helper
@@ -26,11 +43,11 @@ function escapeHtml(text) {
export function renderQuestsSubTabs(activeTab = 'main') { export function renderQuestsSubTabs(activeTab = 'main') {
return ` return `
<div class="rpg-quests-subtabs"> <div class="rpg-quests-subtabs">
<button class="rpg-quests-subtab ${activeTab === 'main' ? 'active' : ''}" data-tab="main" data-i18n-key="quests.section.main"> <button class="rpg-quests-subtab ${activeTab === 'main' ? 'active' : ''}" data-tab="main">
${i18n.getTranslation('quests.section.main')} Main Quest
</button> </button>
<button class="rpg-quests-subtab ${activeTab === 'optional' ? 'active' : ''}" data-tab="optional" data-i18n-key="quests.section.optional"> <button class="rpg-quests-subtab ${activeTab === 'optional' ? 'active' : ''}" data-tab="optional">
${i18n.getTranslation('quests.section.optional')} Optional Quests
</button> </button>
</div> </div>
`; `;
@@ -48,9 +65,9 @@ export function renderMainQuestView(mainQuest) {
return ` return `
<div class="rpg-quest-section"> <div class="rpg-quest-section">
<div class="rpg-quest-header"> <div class="rpg-quest-header">
<h3 class="rpg-quest-section-title" data-i18n-key="quests.main.title">${i18n.getTranslation('quests.main.title')}</h3> <h3 class="rpg-quest-section-title">Main Quests</h3>
${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="${i18n.getTranslation('quests.main.addQuestTitle')}"> ${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="Add main quests">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-plus"></i> Add Quest
</button>` : ''} </button>` : ''}
</div> </div>
<div class="rpg-quest-content"> <div class="rpg-quest-content">
@@ -59,14 +76,15 @@ export function renderMainQuestView(mainQuest) {
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questDisplay)}" /> <input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questDisplay)}" />
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-edit-quest" data-field="main"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-edit-quest" data-field="main">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-edit-quest" data-field="main"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-edit-quest" data-field="main">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.save">${i18n.getTranslation('global.save')}</span> <i class="fa-solid fa-check"></i> Save
</button> </button>
</div> </div>
</div> </div>
<div class="rpg-quest-item" data-field="main"> <div class="rpg-quest-item" data-field="main">
${getLockIconHtml('userStats', 'quests.main')}
<div class="rpg-quest-title">${escapeHtml(questDisplay)}</div> <div class="rpg-quest-title">${escapeHtml(questDisplay)}</div>
<div class="rpg-quest-actions"> <div class="rpg-quest-actions">
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="Edit quest"> <button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="Edit quest">
@@ -79,22 +97,22 @@ export function renderMainQuestView(mainQuest) {
</div> </div>
` : ` ` : `
<div class="rpg-inline-form" id="rpg-add-quest-form-main" style="display: none;"> <div class="rpg-inline-form" id="rpg-add-quest-form-main" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="${i18n.getTranslation('quests.main.addQuestPlaceholder')}" data-i18n-placeholder-key="quests.main.addQuestPlaceholder" /> <input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="Enter main quests title..." />
<div class="rpg-inline-actions"> <div class="rpg-inline-actions">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="main"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="main">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="main"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="main">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-check"></i> Add
</button> </button>
</div> </div>
</div> </div>
<div class="rpg-quest-empty" data-i18n-key="quests.main.empty">${i18n.getTranslation('quests.main.empty')}</div> <div class="rpg-quest-empty">No active main quests</div>
`} `}
</div> </div>
<div class="rpg-quest-hint"> <div class="rpg-quest-hint">
<i class="fa-solid fa-lightbulb"></i> <i class="fa-solid fa-lightbulb"></i>
<span data-i18n-key="quests.main.hint">${i18n.getTranslation('quests.main.hint')}</span> The main quests represent your primary objective in the story.
</div> </div>
</div> </div>
`; `;
@@ -110,10 +128,12 @@ export function renderOptionalQuestsView(optionalQuests) {
let questsHtml = ''; let questsHtml = '';
if (quests.length === 0) { if (quests.length === 0) {
questsHtml = `<div class="rpg-quest-empty" data-i18n-key="quests.optional.empty">${i18n.getTranslation('quests.optional.empty')}</div>`; questsHtml = '<div class="rpg-quest-empty">No active optional quests</div>';
} else { } else {
questsHtml = quests.map((quest, index) => ` questsHtml = quests.map((quest, index) => {
return `
<div class="rpg-quest-item" data-field="optional" data-index="${index}"> <div class="rpg-quest-item" data-field="optional" data-index="${index}">
${getLockIconHtml('userStats', `quests.optional[${index}]`)}
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest)}</div> <div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest)}</div>
<div class="rpg-quest-actions"> <div class="rpg-quest-actions">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="Complete/Remove quest"> <button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="Complete/Remove quest">
@@ -121,26 +141,26 @@ export function renderOptionalQuestsView(optionalQuests) {
</button> </button>
</div> </div>
</div> </div>
`).join(''); `}).join('');
} }
return ` return `
<div class="rpg-quest-section"> <div class="rpg-quest-section">
<div class="rpg-quest-header"> <div class="rpg-quest-header">
<h3 class="rpg-quest-section-title" data-i18n-key="quests.optional.title">${i18n.getTranslation('quests.optional.title')}</h3> <h3 class="rpg-quest-section-title">Optional Quests</h3>
<button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="${i18n.getTranslation('quests.optional.addQuestTitle')}"> <button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="Add optional quest">
<i class="fa-solid fa-plus"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-plus"></i> Add Quest
</button> </button>
</div> </div>
<div class="rpg-quest-content"> <div class="rpg-quest-content">
<div class="rpg-inline-form" id="rpg-add-quest-form-optional" style="display: none;"> <div class="rpg-inline-form" id="rpg-add-quest-form-optional" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="${i18n.getTranslation('quests.optional.addQuestPlaceholder')}" data-i18n-placeholder-key="quests.optional.addQuestPlaceholder" /> <input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="Enter optional quest title..." />
<div class="rpg-inline-buttons"> <div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="optional"> <button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="optional">
<i class="fa-solid fa-times"></i> <span data-i18n-key="global.cancel">${i18n.getTranslation('global.cancel')}</span> <i class="fa-solid fa-times"></i> Cancel
</button> </button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="optional"> <button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="optional">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.add">${i18n.getTranslation('global.add')}</span> <i class="fa-solid fa-check"></i> Add
</button> </button>
</div> </div>
</div> </div>
@@ -149,7 +169,7 @@ export function renderOptionalQuestsView(optionalQuests) {
</div> </div>
<div class="rpg-quest-hint"> <div class="rpg-quest-hint">
<i class="fa-solid fa-info-circle"></i> <i class="fa-solid fa-info-circle"></i>
<span data-i18n-key="quests.optional.hint">${i18n.getTranslation('quests.optional.hint')}</span> Optional quests are side objectives that complement your main story.
</div> </div>
</div> </div>
</div> </div>
@@ -160,7 +180,7 @@ export function renderOptionalQuestsView(optionalQuests) {
* Main render function for quests * Main render function for quests
*/ */
export function renderQuests() { export function renderQuests() {
if (!extensionSettings.showQuests || !$questsContainer) { if (!extensionSettings.showInventory || !$questsContainer) {
return; return;
} }
@@ -304,4 +324,29 @@ function attachQuestEventHandlers() {
} }
} }
}); });
// Add event listener for section lock icon clicks (support both click and touch)
$questsContainer.find('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked' : 'Unlocked';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
} }
File diff suppressed because it is too large Load Diff
+214 -33
View File
@@ -19,6 +19,7 @@ import {
} from '../../core/persistence.js'; } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { buildInventorySummary } from '../generation/promptBuilder.js'; import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
/** /**
* Builds the user stats text string using custom stat names * Builds the user stats text string using custom stat names
@@ -67,6 +68,107 @@ export function buildUserStatsText() {
return text.trim(); return text.trim();
} }
/**
* Updates lastGeneratedData.userStats and committedTrackerData.userStats
* Maintains JSON format if current data is JSON, otherwise uses text format.
* @private
*/
function updateUserStatsData() {
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {};
const enabledStats = config.customStats?.filter(stat => stat && stat.enabled && stat.name && stat.id) || [];
// Build stats array - include all stats from extensionSettings, not just enabled ones
// This preserves custom stats that AI might have added or that user has disabled
const statsArray = [];
const processedIds = new Set();
// First, add all enabled stats from config (maintains order)
enabledStats.forEach(stat => {
statsArray.push({
id: stat.id,
name: stat.name,
value: stats[stat.id] !== undefined ? stats[stat.id] : 100
});
processedIds.add(stat.id);
});
// Then, add any other numeric stats from extensionSettings that aren't in config
// (these could be custom stats the AI added or disabled stats)
const excludeFields = new Set(['mood', 'conditions', 'inventory', 'skills', 'level']);
Object.entries(stats).forEach(([key, value]) => {
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
statsArray.push({
id: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
value: value
});
}
});
jsonData.stats = statsArray;
// Update status
jsonData.status = {
mood: stats.mood || '😐',
conditions: stats.conditions || 'None'
};
// Update inventory (convert to v3 format)
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\\d+)x\\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory?.onPerson),
clothing: convertToV3Items(stats.inventory?.clothing),
stored: stats.inventory?.stored || {},
assets: convertToV3Items(stats.inventory?.assets)
};
// Update quests
jsonData.quests = extensionSettings.quests || { main: '', optional: [] };
// Update skills if present
if (stats.skills) {
jsonData.skills = Array.isArray(stats.skills) ? stats.skills :
stats.skills.split(',').map(s => s.trim()).filter(s => s);
}
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const statsText = buildUserStatsText();
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
/** /**
* Renders the user stats panel with health bars, mood, inventory, and classic stats. * Renders the user stats panel with health bars, mood, inventory, and classic stats.
* Includes event listeners for editable fields. * Includes event listeners for editable fields.
@@ -77,7 +179,36 @@ export function renderUserStats() {
return; return;
} }
// Don't render if no data exists (e.g., after cache clear)
// Check both lastGeneratedData and committedTrackerData
console.log('[RPG UserStats Render] Checking data:', {
hasLastGenerated: !!lastGeneratedData.userStats,
hasCommitted: !!committedTrackerData.userStats,
lastGeneratedPreview: lastGeneratedData.userStats ? lastGeneratedData.userStats.substring(0, 100) : 'null',
committedPreview: committedTrackerData.userStats ? committedTrackerData.userStats.substring(0, 100) : 'null'
});
if (!lastGeneratedData.userStats && !committedTrackerData.userStats) {
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html('<div class="rpg-inventory-empty">No statuses generated yet</div>');
return;
}
// Use lastGeneratedData if available, otherwise fall back to committed data
if (!lastGeneratedData.userStats && committedTrackerData.userStats) {
lastGeneratedData.userStats = committedTrackerData.userStats;
}
const stats = extensionSettings.userStats; const stats = extensionSettings.userStats;
console.log('[RPG UserStats Render] Current extensionSettings.userStats:', {
health: stats.health,
satiety: stats.satiety,
energy: stats.energy,
hygiene: stats.hygiene,
arousal: stats.arousal,
mood: stats.mood,
conditions: stats.conditions
});
const config = extensionSettings.trackerConfig?.userStats || { const config = extensionSettings.trackerConfig?.userStats || {
customStats: [ customStats: [
{ id: 'health', name: 'Health', enabled: true }, { id: 'health', name: 'Health', enabled: true },
@@ -116,20 +247,32 @@ export function renderUserStats() {
// Create gradient from low to high color // Create gradient from low to high color
const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`; const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`;
let html = '<div class="rpg-stats-content"><div class="rpg-stats-left">'; // Check if stats bars section is locked
const isStatsLocked = isItemLocked('userStats', 'stats');
const lockIcon = isStatsLocked ? '🔒' : '🔓';
const lockTitle = isStatsLocked ? 'Locked - AI cannot change stats' : 'Unlocked - AI can change stats';
const lockedClass = isStatsLocked ? ' locked' : '';
let html = '<div class="rpg-stats-content">';
html += '<div class="rpg-stats-left">';
// User info row // User info row
const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false;
html += ` html += `
<div class="rpg-user-info-row"> <div class="rpg-user-info-row">
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" /> <img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
<span class="rpg-user-name">${userName}</span> <span class="rpg-user-name">${userName}</span>
<span style="opacity: 0.5;">|</span> ${showLevel ? `<span style="opacity: 0.5;">|</span>
<span class="rpg-level-label">LVL</span> <span class="rpg-level-label">LVL</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${extensionSettings.level}</span> <span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${extensionSettings.level}</span>` : ''}
</div> </div>
`; `;
// Dynamic stats grid - only show enabled stats // Dynamic stats grid - only show enabled stats
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="userStats" data-path="stats" title="${lockTitle}">${lockIcon}</span>`;
}
html += '<div class="rpg-stats-grid">'; html += '<div class="rpg-stats-grid">';
const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id); const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id);
@@ -149,7 +292,14 @@ export function renderUserStats() {
// Status section (conditionally rendered) // Status section (conditionally rendered)
if (config.statusSection.enabled) { if (config.statusSection.enabled) {
const isMoodLocked = isItemLocked('userStats', 'status');
const moodLockIcon = isMoodLocked ? '🔒' : '🔓';
const moodLockTitle = isMoodLocked ? 'Locked - AI cannot change mood' : 'Unlocked - AI can change mood';
const moodLockedClass = isMoodLocked ? ' locked' : '';
html += '<div class="rpg-mood">'; html += '<div class="rpg-mood">';
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${moodLockedClass}" data-tracker="userStats" data-path="status" title="${moodLockTitle}">${moodLockIcon}</span>`;
}
if (config.statusSection.showMoodEmoji) { if (config.statusSection.showMoodEmoji) {
html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>`; html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>`;
@@ -158,7 +308,11 @@ export function renderUserStats() {
// Render custom status fields // Render custom status fields
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) { if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
// For now, use first field as "conditions" for backward compatibility // For now, use first field as "conditions" for backward compatibility
const conditionsValue = stats.conditions || 'None'; let conditionsValue = stats.conditions || 'None';
// Strip brackets if present (from JSON array format)
if (typeof conditionsValue === 'string') {
conditionsValue = conditionsValue.replace(/^\[|\]$/g, '').trim();
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${conditionsValue}</div>`; html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${conditionsValue}</div>`;
} }
@@ -167,9 +321,24 @@ export function renderUserStats() {
// Skills section (conditionally rendered) // Skills section (conditionally rendered)
if (config.skillsSection.enabled) { if (config.skillsSection.enabled) {
const skillsValue = stats.skills || 'None'; const isSkillsLocked = isItemLocked('userStats', 'skills');
const skillsLockIcon = isSkillsLocked ? '🔒' : '🔓';
const skillsLockTitle = isSkillsLocked ? 'Locked - AI cannot change skills' : 'Unlocked - AI can change skills';
const skillsLockedClass = isSkillsLocked ? ' locked' : '';
let skillsValue = 'None';
// Handle JSON array format: [{name: "Art"}, {name: "Coding"}]
if (Array.isArray(stats.skills)) {
skillsValue = stats.skills.map(s => s.name || s).join(', ') || 'None';
} else if (stats.skills) {
skillsValue = stats.skills;
}
html += `
<div class="rpg-skills-section">`;
if (showLockIcons) {
html += `
<span class="rpg-section-lock-icon${skillsLockedClass}" data-tracker="userStats" data-path="skills" title="${skillsLockTitle}">${skillsLockIcon}</span>`;
}
html += ` html += `
<div class="rpg-skills-section">
<span class="rpg-skills-label">${config.skillsSection.label}:</span> <span class="rpg-skills-label">${config.skillsSection.label}:</span>
<div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="Click to edit skills">${skillsValue}</div> <div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="Click to edit skills">${skillsValue}</div>
</div> </div>
@@ -225,7 +394,13 @@ export function renderUserStats() {
html += '</div>'; // Close rpg-stats-content html += '</div>'; // Close rpg-stats-content
console.log('[RPG UserStats Render] Generated HTML length:', html.length);
console.log('[RPG UserStats Render] HTML preview:', html.substring(0, 300));
console.log('[RPG UserStats Render] Container exists:', !!$userStatsContainer, '$userStatsContainer length:', $userStatsContainer?.length);
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html(html); $userStatsContainer.html(html);
console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container');
// Add event listeners for editable stat values // Add event listeners for editable stat values
$('.rpg-editable-stat').on('blur', function() { $('.rpg-editable-stat').on('blur', function() {
@@ -242,13 +417,8 @@ export function renderUserStats() {
// Update the setting // Update the setting
extensionSettings.userStats[field] = value; extensionSettings.userStats[field] = value;
// Rebuild userStats text with custom stat names // Update userStats data (maintains JSON or text format)
const statsText = buildUserStatsText(); updateUserStatsData();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings(); saveSettings();
saveChatData(); saveChatData();
@@ -263,13 +433,8 @@ export function renderUserStats() {
const value = $(this).text().trim(); const value = $(this).text().trim();
extensionSettings.userStats.mood = value || '😐'; extensionSettings.userStats.mood = value || '😐';
// Rebuild userStats text with custom stat names // Update userStats data (maintains JSON or text format)
const statsText = buildUserStatsText(); updateUserStatsData();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings(); saveSettings();
saveChatData(); saveChatData();
@@ -280,13 +445,8 @@ export function renderUserStats() {
const value = $(this).text().trim(); const value = $(this).text().trim();
extensionSettings.userStats.conditions = value || 'None'; extensionSettings.userStats.conditions = value || 'None';
// Rebuild userStats text with custom stat names // Update userStats data (maintains JSON or text format)
const statsText = buildUserStatsText(); updateUserStatsData();
// Update BOTH lastGeneratedData AND committedTrackerData
// This makes manual edits immediately visible to AI
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings(); saveSettings();
saveChatData(); saveChatData();
@@ -298,12 +458,8 @@ export function renderUserStats() {
const value = $(this).text().trim(); const value = $(this).text().trim();
extensionSettings.userStats.skills = value || 'None'; extensionSettings.userStats.skills = value || 'None';
// Rebuild userStats text // Update userStats data (maintains JSON or text format)
const statsText = buildUserStatsText(); updateUserStatsData();
// Update BOTH lastGeneratedData AND committedTrackerData
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
saveSettings(); saveSettings();
saveChatData(); saveChatData();
@@ -359,4 +515,29 @@ export function renderUserStats() {
$(this).blur(); $(this).blur();
} }
}); });
// Add event listener for section lock icon clicks (support both click and touch)
$('.rpg-section-lock-icon').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked - AI cannot change this section' : 'Unlocked - AI can change this section';
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
} }
-220
View File
@@ -1,220 +0,0 @@
/**
* Debug UI Module
* Provides mobile-friendly debug log viewer for troubleshooting parsing issues
*/
import { extensionSettings, getDebugLogs, clearDebugLogs } from '../../core/state.js';
/**
* Creates and injects the debug panel into the page
* Note: Debug toggle button is created in index.js, not here
*/
export function createDebugPanel() {
// Remove existing debug panel if any
$('#rpg-debug-panel').remove();
// Create debug panel HTML
const debugPanelHtml = `
<div id="rpg-debug-panel" class="rpg-debug-panel">
<div class="rpg-debug-header">
<h3>🔍 Debug Logs</h3>
<div class="rpg-debug-actions">
<button id="rpg-debug-copy" title="Copy logs to clipboard">
<i class="fa-solid fa-copy"></i>
</button>
<button id="rpg-debug-clear" title="Clear logs">
<i class="fa-solid fa-trash"></i>
</button>
<button id="rpg-debug-close" title="Close debug panel">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div id="rpg-debug-logs" class="rpg-debug-logs"></div>
</div>
`;
// Append to body
$('body').append(debugPanelHtml);
// Set up event handlers
setupDebugEventHandlers();
// Initial log render
renderDebugLogs();
}
/**
* Closes the debug panel with proper animation (mobile or desktop)
*/
function closeDebugPanel() {
const $panel = $('#rpg-debug-panel');
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: animate slide-out to right
$panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing');
// Wait for animation to complete before hiding
$panel.one('animationend', function() {
$panel.removeClass('rpg-mobile-closing');
$('.rpg-mobile-overlay').remove();
});
} else {
// Desktop: simple slide-down
$panel.removeClass('rpg-debug-open');
}
}
/**
* Sets up event handlers for debug panel using event delegation for mobile compatibility
*/
function setupDebugEventHandlers() {
// Use event delegation for better mobile compatibility and reliability with dynamic elements
// Remove any existing handlers first to prevent duplicates
$(document).off('click.rpgDebug');
// Toggle button
$(document).on('click.rpgDebug', '#rpg-debug-toggle', function() {
const $debugToggle = $(this);
// Skip if we just finished dragging
if ($debugToggle.data('just-dragged')) {
console.log('[RPG Debug] Click blocked - just finished dragging');
return;
}
const $panel = $('#rpg-debug-panel');
const isMobile = window.innerWidth <= 1000;
if (isMobile) {
// Mobile: use rpg-mobile-open class with slide-from-right animation
const isOpen = $panel.hasClass('rpg-mobile-open');
if (isOpen) {
// Close with animation
closeDebugPanel();
} else {
// Open with animation
$panel.addClass('rpg-mobile-open');
renderDebugLogs();
// Create overlay for mobile
const $overlay = $('<div class="rpg-mobile-overlay"></div>');
$('body').append($overlay);
// Close when clicking overlay
$overlay.on('click', function() {
closeDebugPanel();
});
}
} else {
// Desktop: use rpg-debug-open class with slide-from-bottom animation
$panel.toggleClass('rpg-debug-open');
renderDebugLogs();
}
});
// Close button
$(document).on('click.rpgDebug', '#rpg-debug-close', function(e) {
e.preventDefault();
e.stopPropagation();
closeDebugPanel();
});
// Copy button
$(document).on('click.rpgDebug', '#rpg-debug-copy', function() {
const logs = getDebugLogs();
const logsText = logs.map(log => {
let text = `[${log.timestamp}] ${log.message}`;
if (log.data) {
text += `\n${log.data}`;
}
return text;
}).join('\n\n');
navigator.clipboard.writeText(logsText).then(() => {
// Show feedback
const $btn = $(this);
const $icon = $btn.find('i');
$icon.removeClass('fa-copy').addClass('fa-check');
setTimeout(() => {
$icon.removeClass('fa-check').addClass('fa-copy');
}, 1500);
}).catch(err => {
console.error('Failed to copy logs:', err);
alert('Failed to copy logs. Please use browser console instead.');
});
});
// Clear button
$(document).on('click.rpgDebug', '#rpg-debug-clear', function() {
if (confirm('Clear all debug logs?')) {
clearDebugLogs();
renderDebugLogs();
}
});
}
/**
* Renders debug logs to the panel
*/
function renderDebugLogs() {
const logs = getDebugLogs();
const $logsContainer = $('#rpg-debug-logs');
if (logs.length === 0) {
$logsContainer.html('<div class="rpg-debug-empty">No logs yet. Logs will appear when parser runs.</div>');
return;
}
// Build logs HTML
const logsHtml = logs.map(log => {
let html = `<div class="rpg-debug-entry">`;
html += `<span class="rpg-debug-time">[${log.timestamp}]</span> `;
html += `<span class="rpg-debug-message">${escapeHtml(log.message)}</span>`;
if (log.data) {
html += `<pre class="rpg-debug-data">${escapeHtml(log.data)}</pre>`;
}
html += `</div>`;
return html;
}).join('');
$logsContainer.html(logsHtml);
// Auto-scroll to bottom
$logsContainer[0].scrollTop = $logsContainer[0].scrollHeight;
}
/**
* Escapes HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Shows or hides debug UI based on debug mode setting
* Note: Debug toggle button always exists in DOM (created in index.js)
*/
export function updateDebugUIVisibility() {
const $debugToggle = $('#rpg-debug-toggle');
if (extensionSettings.debugMode) {
// Show debug toggle button
$debugToggle.css('display', 'flex');
// Create debug panel if it doesn't exist
if ($('#rpg-debug-panel').length === 0) {
createDebugPanel();
}
} else {
// Hide debug toggle button
$debugToggle.css('display', 'none');
// Remove debug panel
$('#rpg-debug-panel').remove();
}
}
+9 -2
View File
@@ -79,7 +79,11 @@ export function setupDesktopTabs() {
} }
if ($infoBox.length > 0) { if ($infoBox.length > 0) {
$statusTab.append($infoBox.detach()); $statusTab.append($infoBox.detach());
if (extensionSettings.showInfoBox) $infoBox.show(); // Only show if enabled and has data
if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
} }
if ($thoughts.length > 0) { if ($thoughts.length > 0) {
$statusTab.append($thoughts.detach()); $statusTab.append($thoughts.detach());
@@ -170,7 +174,10 @@ export function removeDesktopTabs() {
// Show/hide sections based on settings (respect visibility settings) // Show/hide sections based on settings (respect visibility settings)
if (extensionSettings.showUserStats) $userStats.show(); if (extensionSettings.showUserStats) $userStats.show();
if (extensionSettings.showInfoBox) $infoBox.show(); if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if (extensionSettings.showCharacterThoughts) $thoughts.show(); if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show(); if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showQuests) $quests.show(); if (extensionSettings.showQuests) $quests.show();
+55 -30
View File
@@ -13,7 +13,9 @@ import {
$questsContainer, $questsContainer,
$musicPlayerContainer, $musicPlayerContainer,
setInventoryContainer, setInventoryContainer,
setQuestsContainer setQuestsContainer,
lastGeneratedData,
committedTrackerData
} from '../../core/state.js'; } from '../../core/state.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { setupMobileTabs, removeMobileTabs } from './mobile.js'; import { setupMobileTabs, removeMobileTabs } from './mobile.js';
@@ -28,12 +30,17 @@ export function togglePlotButtons() {
return; return;
} }
// Show/hide plot progression buttons based on enablePlotButtons setting // Show/hide randomized plot button based on enableRandomizedPlot setting
if (extensionSettings.enablePlotButtons) { if (extensionSettings.enableRandomizedPlot) {
$('#rpg-plot-random').show(); $('#rpg-plot-random').show();
$('#rpg-plot-natural').show();
} else { } else {
$('#rpg-plot-random').hide(); $('#rpg-plot-random').hide();
}
// Show/hide natural plot button based on enableNaturalPlot setting
if (extensionSettings.enableNaturalPlot) {
$('#rpg-plot-natural').show();
} else {
$('#rpg-plot-natural').hide(); $('#rpg-plot-natural').hide();
} }
@@ -45,7 +52,7 @@ export function togglePlotButtons() {
} }
// Show the container if at least one button is visible // Show the container if at least one button is visible
const shouldShowContainer = extensionSettings.enablePlotButtons || extensionSettings.encounterSettings?.enabled; const shouldShowContainer = extensionSettings.enableRandomizedPlot || extensionSettings.enableNaturalPlot || extensionSettings.encounterSettings?.enabled;
if (shouldShowContainer) { if (shouldShowContainer) {
$('#rpg-plot-buttons').show(); $('#rpg-plot-buttons').show();
} else { } else {
@@ -81,19 +88,34 @@ export function updateCollapseToggleIcon() {
const isMobile = window.innerWidth <= 1000; const isMobile = window.innerWidth <= 1000;
if (isMobile) { if (isMobile) {
// Mobile: slides from right, use same icon logic as desktop right panel // Mobile: icon direction based on panel position and open state
const isOpen = $panel.hasClass('rpg-mobile-open'); const isOpen = $panel.hasClass('rpg-mobile-open');
const isLeftPanel = $panel.hasClass('rpg-position-left');
console.log('[RPG Mobile] updateCollapseToggleIcon:', { console.log('[RPG Mobile] updateCollapseToggleIcon:', {
isMobile: true, isMobile: true,
isOpen, isOpen,
settingIcon: isOpen ? 'chevron-left' : 'chevron-right' isLeftPanel,
settingIcon: isOpen ? (isLeftPanel ? 'chevron-left' : 'chevron-right') : (isLeftPanel ? 'chevron-right' : 'chevron-left')
}); });
if (isOpen) {
// Panel open - chevron points left (to close/slide back right) if (isLeftPanel) {
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left'); if (isOpen) {
// Left panel open - chevron points left (panel will slide left to close)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left');
} else {
// Left panel closed - chevron points left (panel is hidden on left)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left');
}
} else { } else {
// Panel closed - chevron points right (to open/slide in from right) // Right panel (default)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right'); if (isOpen) {
// Right panel open - chevron points right (panel will slide right to close)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right');
} else {
// Right panel closed - chevron points right (panel is hidden on right)
$icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right');
}
} }
} else { } else {
// Desktop: icon direction based on panel position and collapsed state // Desktop: icon direction based on panel position and collapsed state
@@ -237,14 +259,11 @@ export function updatePanelVisibility() {
togglePlotButtons(); // Update plot button visibility togglePlotButtons(); // Update plot button visibility
$('#rpg-mobile-toggle').show(); // Show mobile FAB toggle $('#rpg-mobile-toggle').show(); // Show mobile FAB toggle
$('#rpg-collapse-toggle').show(); // Show collapse toggle $('#rpg-collapse-toggle').show(); // Show collapse toggle
// Debug toggle visibility is controlled by debugMode setting in debug.js
} else { } else {
$panelContainer.hide(); $panelContainer.hide();
$('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled $('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled
$('#rpg-mobile-toggle').hide(); // Hide mobile FAB toggle $('#rpg-mobile-toggle').hide(); // Hide mobile FAB toggle
$('#rpg-collapse-toggle').hide(); // Hide collapse toggle $('#rpg-collapse-toggle').hide(); // Hide collapse toggle
$('#rpg-debug-toggle').hide(); // Hide debug toggle button when extension disabled
$('#rpg-debug-panel').remove(); // Remove debug panel when extension disabled
} }
} }
@@ -265,7 +284,13 @@ export function updateSectionVisibility() {
} }
if (extensionSettings.showInfoBox) { if (extensionSettings.showInfoBox) {
$infoBoxContainer.show(); // Only show if there's data to display
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox;
if (infoBoxData) {
$infoBoxContainer.show();
} else {
$infoBoxContainer.hide();
}
} else { } else {
$infoBoxContainer.hide(); $infoBoxContainer.hide();
} }
@@ -383,16 +408,19 @@ export function applyPanelPosition() {
// Remove all position classes // Remove all position classes
$panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top'); $panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top');
$('body').removeClass('rpg-panel-position-left rpg-panel-position-right rpg-panel-position-top');
// On mobile, don't apply desktop position classes // Add the appropriate position class
$panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`);
// On mobile, also add body class for mobile-specific CSS
if (isMobile) { if (isMobile) {
$('body').addClass(`rpg-panel-position-${extensionSettings.panelPosition}`);
updateCollapseToggleIcon();
return; return;
} }
// Desktop: Add the appropriate position class // Desktop: Update collapse toggle icon direction for new position
$panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`);
// Update collapse toggle icon direction for new position
updateCollapseToggleIcon(); updateCollapseToggleIcon();
} }
@@ -405,24 +433,21 @@ export function updateGenerationModeUI() {
$('#rpg-manual-update').hide(); $('#rpg-manual-update').hide();
$('#rpg-external-api-settings').slideUp(200); $('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideUp(200); $('#rpg-separate-mode-settings').slideUp(200);
// Disable auto-update toggle (not applicable in together mode) // Hide auto-update toggle (not applicable in together mode)
$('#rpg-toggle-auto-update').prop('disabled', true); $('#rpg-auto-update-container').slideUp(200);
$('#rpg-auto-update-container').css('opacity', '0.5');
} else if (extensionSettings.generationMode === 'separate') { } else if (extensionSettings.generationMode === 'separate') {
// In "separate" mode, manual update button is visible // In "separate" mode, manual update button is visible
$('#rpg-manual-update').show(); $('#rpg-manual-update').show();
$('#rpg-external-api-settings').slideUp(200); $('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideDown(200); $('#rpg-separate-mode-settings').slideDown(200);
// Enable auto-update toggle (only works in separate mode) // Show auto-update toggle
$('#rpg-toggle-auto-update').prop('disabled', false); $('#rpg-auto-update-container').slideDown(200);
$('#rpg-auto-update-container').css('opacity', '1');
} else if (extensionSettings.generationMode === 'external') { } else if (extensionSettings.generationMode === 'external') {
// In "external" mode, manual update button is visible AND external settings are shown // In "external" mode, manual update button is visible AND external settings are shown
$('#rpg-manual-update').show(); $('#rpg-manual-update').show();
$('#rpg-external-api-settings').slideDown(200); $('#rpg-external-api-settings').slideDown(200);
$('#rpg-separate-mode-settings').slideUp(200); $('#rpg-separate-mode-settings').slideUp(200);
// Disable auto-update toggle (not applicable in external mode) // Show auto-update toggle for external mode too
$('#rpg-toggle-auto-update').prop('disabled', true); $('#rpg-auto-update-container').slideDown(200);
$('#rpg-auto-update-container').css('opacity', '0.5');
} }
} }
+22 -4
View File
@@ -375,8 +375,12 @@ export function setupMobileToggle() {
// Remove desktop tabs first // Remove desktop tabs first
removeDesktopTabs(); removeDesktopTabs();
// Remove desktop positioning classes // Apply mobile positioning based on panelPosition setting
$panel.removeClass('rpg-position-right rpg-position-left rpg-position-top'); $panel.removeClass('rpg-position-right rpg-position-left rpg-position-top');
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
$('body').addClass('rpg-panel-position-' + position);
// Clear collapsed state - mobile doesn't use collapse // Clear collapsed state - mobile doesn't use collapse
$panel.removeClass('rpg-collapsed'); $panel.removeClass('rpg-collapsed');
@@ -424,7 +428,8 @@ export function setupMobileToggle() {
// Hide mobile toggle button on desktop // Hide mobile toggle button on desktop
$mobileToggle.hide(); $mobileToggle.hide();
// Restore desktop positioning class // Restore desktop positioning class and remove body mobile classes
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right'; const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position); $panel.addClass('rpg-position-' + position);
@@ -561,6 +566,14 @@ export function setupMobileTabs() {
if ($('.rpg-mobile-tabs').length > 0) return; if ($('.rpg-mobile-tabs').length > 0) return;
const $panel = $('#rpg-companion-panel'); const $panel = $('#rpg-companion-panel');
// Apply mobile positioning based on panelPosition setting
$panel.removeClass('rpg-position-right rpg-position-left rpg-position-top');
$('body').removeClass('rpg-panel-position-right rpg-panel-position-left rpg-panel-position-top');
const position = extensionSettings.panelPosition || 'right';
$panel.addClass('rpg-position-' + position);
$('body').addClass('rpg-panel-position-' + position);
const $contentBox = $panel.find('.rpg-content-box'); const $contentBox = $panel.find('.rpg-content-box');
// Get existing sections // Get existing sections
@@ -624,7 +637,9 @@ export function setupMobileTabs() {
// Info tab: Info Box + Character Thoughts // Info tab: Info Box + Character Thoughts
if ($infoBox.length > 0) { if ($infoBox.length > 0) {
$infoTab.append($infoBox.detach()); $infoTab.append($infoBox.detach());
$infoBox.show(); // Only show if has data
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
} }
if ($thoughts.length > 0) { if ($thoughts.length > 0) {
$infoTab.append($thoughts.detach()); $infoTab.append($thoughts.detach());
@@ -714,7 +729,10 @@ export function removeMobileTabs() {
// Show/hide sections based on settings (respect visibility settings) // Show/hide sections based on settings (respect visibility settings)
if (extensionSettings.showUserStats) $userStats.show(); if (extensionSettings.showUserStats) $userStats.show();
if (extensionSettings.showInfoBox) $infoBox.show(); if (extensionSettings.showInfoBox) {
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
if (infoBoxData) $infoBox.show();
}
if (extensionSettings.showCharacterThoughts) $thoughts.show(); if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show(); if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showQuests) $quests.show(); if (extensionSettings.showQuests) $quests.show();
+155 -10
View File
@@ -10,13 +10,17 @@ import {
committedTrackerData, committedTrackerData,
$infoBoxContainer, $infoBoxContainer,
$thoughtsContainer, $thoughtsContainer,
$userStatsContainer,
setPendingDiceRoll, setPendingDiceRoll,
getPendingDiceRoll getPendingDiceRoll,
clearSessionAvatarPrompts
} from '../../core/state.js'; } from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js'; import { saveSettings, saveChatData } from '../../core/persistence.js';
import { renderUserStats } from '../rendering/userStats.js'; import { renderUserStats } from '../rendering/userStats.js';
import { updateChatThoughts } from '../rendering/thoughts.js'; import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderQuests } from '../rendering/quests.js'; import { renderQuests } from '../rendering/quests.js';
import { renderInventory } from '../rendering/inventory.js';
import { import {
rollDice as rollDiceCore, rollDice as rollDiceCore,
clearDiceRoll as clearDiceRollCore, clearDiceRoll as clearDiceRollCore,
@@ -351,18 +355,31 @@ export function setupSettingsPopup() {
// Clear cache button // Clear cache button
$('#rpg-clear-cache').on('click', function() { $('#rpg-clear-cache').on('click', function() {
// Clear the data console.log('[RPG Companion] Clear Cache button clicked');
// Clear the data (set to null so panels show "not generated yet")
lastGeneratedData.userStats = null; lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null; lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null; lastGeneratedData.characterThoughts = null;
lastGeneratedData.html = null;
// Clear committed tracker data (used for generation context) // Clear committed tracker data (used for generation context)
committedTrackerData.userStats = null; committedTrackerData.userStats = null;
committedTrackerData.infoBox = null; committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null; committedTrackerData.characterThoughts = null;
// Clear session avatar prompts
clearSessionAvatarPrompts();
// Clear chat metadata immediately (don't wait for debounced save)
const context = getContext();
if (context.chat_metadata && context.chat_metadata.rpg_companion) {
delete context.chat_metadata.rpg_companion;
console.log('[RPG Companion] Cleared chat_metadata.rpg_companion for current chat');
}
// Clear all message swipe data // Clear all message swipe data
const chat = getContext().chat; const chat = context.chat;
if (chat && chat.length > 0) { if (chat && chat.length > 0) {
for (let i = 0; i < chat.length; i++) { for (let i = 0; i < chat.length; i++) {
const message = chat[i]; const message = chat[i];
@@ -380,8 +397,11 @@ export function setupSettingsPopup() {
if ($thoughtsContainer) { if ($thoughtsContainer) {
$thoughtsContainer.empty(); $thoughtsContainer.empty();
} }
if ($userStatsContainer) {
$userStatsContainer.empty();
}
// Reset stats to defaults and re-render // Reset user stats to default object structure (extensionSettings stores as object, not JSON string)
extensionSettings.userStats = { extensionSettings.userStats = {
health: 100, health: 100,
satiety: 100, satiety: 100,
@@ -390,7 +410,29 @@ export function setupSettingsPopup() {
arousal: 0, arousal: 0,
mood: '😐', mood: '😐',
conditions: 'None', conditions: 'None',
inventory: 'None' skills: [],
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
};
// Reset info box to defaults (as object)
extensionSettings.infoBox = {
date: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
weather: '☀️ Clear skies',
temperature: '20°C',
time: '00:00 - 00:00',
location: 'Unknown Location',
recentEvents: []
};
// Reset character thoughts to empty (as object)
extensionSettings.characterThoughts = {
characters: []
}; };
// Reset classic stats (attributes) to defaults // Reset classic stats (attributes) to defaults
@@ -406,23 +448,54 @@ export function setupSettingsPopup() {
// Clear dice roll // Clear dice roll
extensionSettings.lastDiceRoll = null; extensionSettings.lastDiceRoll = null;
// Reset level to 1
extensionSettings.level = 1;
// Clear quests // Clear quests
extensionSettings.quests = { extensionSettings.quests = {
main: "None", main: "None",
optional: [] optional: []
}; };
// Clear all locked items
extensionSettings.lockedItems = {
stats: [],
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: false,
optional: []
},
infoBox: {
date: false,
weather: false,
temperature: false,
time: false,
location: false,
recentEvents: false
},
characters: {}
};
// Save everything // Save everything
saveChatData(); saveChatData();
saveSettings(); saveSettings();
// Re-render user stats and dice display // Re-render all panels - they will show "not generated yet" messages since data is null
renderUserStats(); renderUserStats();
renderInfoBox();
renderThoughts();
updateDiceDisplayCore(); updateDiceDisplayCore();
updateChatThoughts(); // Clear the thought bubble in chat updateChatThoughts();
renderQuests(); // Clear and re-render quests UI renderInventory();
renderQuests();
// console.log('[RPG Companion] Chat cache cleared'); console.log('[RPG Companion] Cache cleared successfully');
}); });
return settingsModal; return settingsModal;
@@ -508,3 +581,75 @@ export function addDiceQuickReply() {
export function getSettingsModal() { export function getSettingsModal() {
return settingsModal; return settingsModal;
} }
/**
* Shows the welcome modal for v3.0.0 on first launch
* Checks if user has already seen this version's welcome screen
*/
export function showWelcomeModalIfNeeded() {
const WELCOME_VERSION = '3.0.0';
const STORAGE_KEY = 'rpg_companion_welcome_seen';
try {
const seenVersion = localStorage.getItem(STORAGE_KEY);
// If user hasn't seen v3.0.0 welcome yet, show it
if (seenVersion !== WELCOME_VERSION) {
showWelcomeModal(WELCOME_VERSION, STORAGE_KEY);
}
} catch (error) {
console.error('[RPG Companion] Failed to check welcome modal status:', error);
}
}
/**
* Shows the welcome modal
* @param {string} version - The version to mark as seen
* @param {string} storageKey - The localStorage key to use
*/
function showWelcomeModal(version, storageKey) {
const modal = document.getElementById('rpg-welcome-modal');
if (!modal) {
console.error('[RPG Companion] Welcome modal element not found');
return;
}
// Apply current theme to modal
const theme = extensionSettings.theme || 'default';
modal.setAttribute('data-theme', theme);
// Show modal
modal.style.display = 'flex';
modal.classList.add('is-open');
// Close button handler
const closeBtn = document.getElementById('rpg-welcome-close');
const gotItBtn = document.getElementById('rpg-welcome-got-it');
const closeModal = () => {
modal.classList.add('is-closing');
setTimeout(() => {
modal.style.display = 'none';
modal.classList.remove('is-open', 'is-closing');
}, 200);
// Mark this version as seen
try {
localStorage.setItem(storageKey, version);
} catch (error) {
console.error('[RPG Companion] Failed to save welcome modal status:', error);
}
};
// Attach event listeners
closeBtn?.addEventListener('click', closeModal, { once: true });
gotItBtn?.addEventListener('click', closeModal, { once: true });
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
}, { once: true });
}
+30 -20
View File
@@ -4,7 +4,7 @@
*/ */
import { extensionSettings } from '../../core/state.js'; import { extensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js'; import { saveSettings } from '../../core/persistence.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_SPOTIFY_PROMPT } from '../generation/promptBuilder.js'; import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_SPOTIFY_PROMPT, DEFAULT_NARRATOR_PROMPT } from '../generation/promptBuilder.js';
let $editorModal = null; let $editorModal = null;
let tempPrompts = null; // Temporary prompts for cancel functionality let tempPrompts = null; // Temporary prompts for cancel functionality
@@ -12,28 +12,22 @@ let tempPrompts = null; // Temporary prompts for cancel functionality
// Default prompts // Default prompts
const DEFAULT_PROMPTS = { const DEFAULT_PROMPTS = {
html: DEFAULT_HTML_PROMPT, html: DEFAULT_HTML_PROMPT,
dialogueColoring: DEFAULT_DIALOGUE_COLORING_PROMPT,
spotify: DEFAULT_SPOTIFY_PROMPT, spotify: DEFAULT_SPOTIFY_PROMPT,
narrator: DEFAULT_NARRATOR_PROMPT,
plotRandom: 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.', plotRandom: 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.',
plotNatural: 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.', plotNatural: 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.',
avatar: `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons, but your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable. avatar: `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons; however, your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable.
Your workflow strictly follows a logical sequence:
Your workflow strictly follows a logical sequence: First, establish the subject. If the character is from a known Intellectual Property (IP), franchise, anime, game, or movie, you MUST begin the prompt with their full name and the series title (e.g., "Nami from One Piece", "Geralt of Rivia from The Witcher"). This is the single most important anchor for the image and must take precedence. If the character is original, clearly describe their core identity, race, and appearance.
Next, set the framing. This is an avatar portrait. Focus strictly on the character's face and upper shoulders (a bust shot or close-up). Ensure the face is the central focal point.
First, **establish the subject**. If the character is from a known Intellectual Property (IP), franchise, anime, game, or movie, **you MUST begin the prompt with their full name and the series title** (e.g., "Nami from One Piece", "Geralt of Rivia from The Witcher"). This is the single most important anchor for the image and must take precedence. If the character is original, clearly describe their core identity, race, and appearance. Then, integrate the setting. Describe the character within their current environment as provided in the context, but keep it as a background element. Incorporate the lighting, weather, and atmosphere to influence the character's appearance (e.g., shadows on the face, wet hair from rain).
Next, detail the facial specifics. Describe the character's current expression, eye contact, and mood in great detail based on the scene context and their personality. Mention visible clothing only at the neckline/shoulders.
Next, **set the framing**. This is an avatar portrait. Focus strictly on the character's face and upper shoulders (bust shot or close-up). Ensure the face is the central focal point. Finally, infuse with aesthetics. Define the artistic style, medium (e.g., digital art, oil painting), and visual tone (e.g., cinematic lighting, ethereal atmosphere).
Your final description must be objective and concrete, and the use of metaphors and emotional rhetoric is strictly prohibited. It must also not contain meta tags or drawing instructions such as "8K" or "masterpiece".
Then, **integrate the setting**. Describe the character *within* their current environment as provided in the context, but keep it as a background element. Incorporate the lighting, weather, and atmosphere to influence the character's appearance (e.g., shadows on the face, wet hair from rain). Output only the final, modified prompt; do not output anything else.`,
trackerInstructions: 'Replace X with actual numbers (e.g., 69) and replace all placeholders with concrete in-world details that {userName} perceives about the current scene and the present characters. For example: "Location" becomes Forest Clearing, "Mood Emoji" becomes "😊". Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user\'s actions, the passage of time, and logical consequences (0% if the time progressed only by a few minutes, 1-5% normally, and above 5% only if a major time-skip/event occurs).',
Next, **detail the facial specifics**. Describe the character's current expression, eye contact, and mood in high detail based on the scene context and their personality. Mention visible clothing only at the neckline/shoulders. trackerContinuation: 'After updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting the protagonist\'s performance, low hygiene influencing their social interactions, environmental factors shaping the scene, a character\'s emotional state coloring their responses, and so on. Remember, all placeholders (e.g., "Location", "Mood Emoji") MUST be replaced with actual content.',
Finally, **infuse with aesthetics**. Define the artistic style, medium (e.g., digital art, oil painting), and visual tone (e.g., cinematic lighting, ethereal atmosphere).
Your final description must be objective and concrete, and the use of metaphors and emotional rhetoric is strictly prohibited. It must also not contain meta tags or drawing instructions such as "8K" or "masterpiece".
Output only the final, modified prompt; do not output anything else.`,
trackerInstructions: 'Replace X with actual numbers (e.g., 69) and replace all [placeholders] with concrete in-world details that {userName} perceives about the current scene and the present characters. Do NOT keep the brackets or placeholder text in your response. For example: [Location] becomes Forest Clearing, [Mood Emoji] becomes 😊. Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user\'s actions, the passage of time, and logical consequences (0% if the time progressed only by a few minutes, 1-5% normally, and above 5% only if a major time-skip/event occurs).',
trackerContinuation: 'After updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting the protagonist\'s performance, low hygiene influencing their social interactions, environmental factors shaping the scene, a character\'s emotional state coloring their responses, and so on. Remember, all bracketed placeholders (e.g., [Location], [Mood Emoji]) MUST be replaced with actual content without the square brackets.',
combatNarrative: 'Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don\'t fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn\'t/didn\'t." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn\'t. Absolutely no asterisks, ellipses, or em-dashes. Explicit content is allowed, no plot armor. Do not play for {userName}. Keep your response length under 150 words. Never end on handover cues; finish naturally.\nCRITICAL: Do not repeat, echo, parrot, or restate distinctive words, phrases, and dialogues from the user\'s last message. If reacting to speech, show interpretation or response, not repetition.\nEXAMPLE: "Are you a gooner?" User asks.\nBAD: "Gooner?"\nGOOD: A flat look. "What type of question is that?"' combatNarrative: 'Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don\'t fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn\'t/didn\'t." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn\'t. Absolutely no asterisks, ellipses, or em-dashes. Explicit content is allowed, no plot armor. Do not play for {userName}. Keep your response length under 150 words. Never end on handover cues; finish naturally.\nCRITICAL: Do not repeat, echo, parrot, or restate distinctive words, phrases, and dialogues from the user\'s last message. If reacting to speech, show interpretation or response, not repetition.\nEXAMPLE: "Are you a gooner?" User asks.\nBAD: "Gooner?"\nGOOD: A flat look. "What type of question is that?"'
}; };
@@ -98,7 +92,9 @@ function openPromptsEditor() {
// Create temporary copy for cancel functionality // Create temporary copy for cancel functionality
tempPrompts = { tempPrompts = {
html: extensionSettings.customHtmlPrompt || '', html: extensionSettings.customHtmlPrompt || '',
dialogueColoring: extensionSettings.customDialogueColoringPrompt || '',
spotify: extensionSettings.customSpotifyPrompt || '', spotify: extensionSettings.customSpotifyPrompt || '',
narrator: extensionSettings.customNarratorPrompt || '',
plotRandom: extensionSettings.customPlotRandomPrompt || '', plotRandom: extensionSettings.customPlotRandomPrompt || '',
plotNatural: extensionSettings.customPlotNaturalPrompt || '', plotNatural: extensionSettings.customPlotNaturalPrompt || '',
avatar: extensionSettings.avatarLLMCustomInstruction || '', avatar: extensionSettings.avatarLLMCustomInstruction || '',
@@ -109,7 +105,9 @@ function openPromptsEditor() {
// Load current values or defaults // Load current values or defaults
$('#rpg-prompt-html').val(extensionSettings.customHtmlPrompt || DEFAULT_PROMPTS.html); $('#rpg-prompt-html').val(extensionSettings.customHtmlPrompt || DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(extensionSettings.customDialogueColoringPrompt || DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-spotify').val(extensionSettings.customSpotifyPrompt || DEFAULT_PROMPTS.spotify); $('#rpg-prompt-spotify').val(extensionSettings.customSpotifyPrompt || DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(extensionSettings.customNarratorPrompt || DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-plot-random').val(extensionSettings.customPlotRandomPrompt || DEFAULT_PROMPTS.plotRandom); $('#rpg-prompt-plot-random').val(extensionSettings.customPlotRandomPrompt || DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(extensionSettings.customPlotNaturalPrompt || DEFAULT_PROMPTS.plotNatural); $('#rpg-prompt-plot-natural').val(extensionSettings.customPlotNaturalPrompt || DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(extensionSettings.avatarLLMCustomInstruction || DEFAULT_PROMPTS.avatar); $('#rpg-prompt-avatar').val(extensionSettings.avatarLLMCustomInstruction || DEFAULT_PROMPTS.avatar);
@@ -144,7 +142,9 @@ function closePromptsEditor() {
*/ */
function savePrompts() { function savePrompts() {
extensionSettings.customHtmlPrompt = $('#rpg-prompt-html').val().trim(); extensionSettings.customHtmlPrompt = $('#rpg-prompt-html').val().trim();
extensionSettings.customDialogueColoringPrompt = $('#rpg-prompt-dialogue-coloring').val().trim();
extensionSettings.customSpotifyPrompt = $('#rpg-prompt-spotify').val().trim(); extensionSettings.customSpotifyPrompt = $('#rpg-prompt-spotify').val().trim();
extensionSettings.customNarratorPrompt = $('#rpg-prompt-narrator').val().trim();
extensionSettings.customPlotRandomPrompt = $('#rpg-prompt-plot-random').val().trim(); extensionSettings.customPlotRandomPrompt = $('#rpg-prompt-plot-random').val().trim();
extensionSettings.customPlotNaturalPrompt = $('#rpg-prompt-plot-natural').val().trim(); extensionSettings.customPlotNaturalPrompt = $('#rpg-prompt-plot-natural').val().trim();
extensionSettings.avatarLLMCustomInstruction = $('#rpg-prompt-avatar').val().trim(); extensionSettings.avatarLLMCustomInstruction = $('#rpg-prompt-avatar').val().trim();
@@ -168,9 +168,15 @@ function restorePromptToDefault(promptType) {
case 'html': case 'html':
extensionSettings.customHtmlPrompt = ''; extensionSettings.customHtmlPrompt = '';
break; break;
case 'dialogueColoring':
extensionSettings.customDialogueColoringPrompt = '';
break;
case 'spotify': case 'spotify':
extensionSettings.customSpotifyPrompt = ''; extensionSettings.customSpotifyPrompt = '';
break; break;
case 'narrator':
extensionSettings.customNarratorPrompt = '';
break;
case 'plotRandom': case 'plotRandom':
extensionSettings.customPlotRandomPrompt = ''; extensionSettings.customPlotRandomPrompt = '';
break; break;
@@ -199,7 +205,9 @@ function restorePromptToDefault(promptType) {
*/ */
function restoreAllToDefaults() { function restoreAllToDefaults() {
$('#rpg-prompt-html').val(DEFAULT_PROMPTS.html); $('#rpg-prompt-html').val(DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-spotify').val(DEFAULT_PROMPTS.spotify); $('#rpg-prompt-spotify').val(DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-plot-random').val(DEFAULT_PROMPTS.plotRandom); $('#rpg-prompt-plot-random').val(DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(DEFAULT_PROMPTS.plotNatural); $('#rpg-prompt-plot-natural').val(DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(DEFAULT_PROMPTS.avatar); $('#rpg-prompt-avatar').val(DEFAULT_PROMPTS.avatar);
@@ -209,7 +217,9 @@ function restoreAllToDefaults() {
// Clear all custom prompts // Clear all custom prompts
extensionSettings.customHtmlPrompt = ''; extensionSettings.customHtmlPrompt = '';
extensionSettings.customDialogueColoringPrompt = '';
extensionSettings.customSpotifyPrompt = ''; extensionSettings.customSpotifyPrompt = '';
extensionSettings.customNarratorPrompt = '';
extensionSettings.customPlotRandomPrompt = ''; extensionSettings.customPlotRandomPrompt = '';
extensionSettings.customPlotNaturalPrompt = ''; extensionSettings.customPlotNaturalPrompt = '';
extensionSettings.avatarLLMCustomInstruction = ''; extensionSettings.avatarLLMCustomInstruction = '';
+68 -5
View File
@@ -36,6 +36,35 @@ export function applyTheme() {
} }
// For 'default', we do nothing - it will use the CSS variables from .rpg-panel class // For 'default', we do nothing - it will use the CSS variables from .rpg-panel class
// which fall back to SillyTavern's theme variables // which fall back to SillyTavern's theme variables
// Apply theme to mobile toggle and thought elements as well
const $mobileToggle = $('#rpg-mobile-toggle');
const $thoughtIcon = $('#rpg-thought-icon');
const $thoughtPanel = $('#rpg-thought-panel');
if ($mobileToggle.length) {
if (theme === 'default') {
$mobileToggle.removeAttr('data-theme');
} else {
$mobileToggle.attr('data-theme', theme);
}
}
if ($thoughtIcon.length) {
if (theme === 'default') {
$thoughtIcon.removeAttr('data-theme');
} else {
$thoughtIcon.attr('data-theme', theme);
}
}
if ($thoughtPanel.length) {
if (theme === 'default') {
$thoughtPanel.removeAttr('data-theme');
} else {
$thoughtPanel.attr('data-theme', theme);
}
}
} }
/** /**
@@ -46,7 +75,7 @@ export function applyCustomTheme() {
const colors = extensionSettings.customColors; const colors = extensionSettings.customColors;
// Apply custom CSS variables as inline styles // Apply custom CSS variables as inline styles to main panel
$panelContainer.css({ $panelContainer.css({
'--rpg-bg': colors.bg, '--rpg-bg': colors.bg,
'--rpg-accent': colors.accent, '--rpg-accent': colors.accent,
@@ -55,6 +84,32 @@ export function applyCustomTheme() {
'--rpg-border': colors.highlight, '--rpg-border': colors.highlight,
'--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow '--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow
}); });
// Apply custom colors to mobile toggle and thought elements
const customStyles = {
'--rpg-bg': colors.bg,
'--rpg-accent': colors.accent,
'--rpg-text': colors.text,
'--rpg-highlight': colors.highlight,
'--rpg-border': colors.highlight,
'--rpg-shadow': `${colors.highlight}80`
};
const $mobileToggle = $('#rpg-mobile-toggle');
const $thoughtIcon = $('#rpg-thought-icon');
const $thoughtPanel = $('#rpg-thought-panel');
if ($mobileToggle.length) {
$mobileToggle.attr('data-theme', 'custom').css(customStyles);
}
if ($thoughtIcon.length) {
$thoughtIcon.attr('data-theme', 'custom').css(customStyles);
}
if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles);
}
} }
/** /**
@@ -82,21 +137,29 @@ export function toggleAnimations() {
export function updateFeatureTogglesVisibility() { export function updateFeatureTogglesVisibility() {
const $featuresRow = $('#rpg-features-row'); const $featuresRow = $('#rpg-features-row');
const $htmlToggle = $('#rpg-html-toggle-wrapper'); const $htmlToggle = $('#rpg-html-toggle-wrapper');
const $dialogueColoringToggle = $('#rpg-dialogue-coloring-toggle-wrapper');
const $spotifyToggle = $('#rpg-spotify-toggle-wrapper'); const $spotifyToggle = $('#rpg-spotify-toggle-wrapper');
const $snowflakesToggle = $('#rpg-snowflakes-toggle-wrapper');
const $dynamicWeatherToggle = $('#rpg-dynamic-weather-toggle-wrapper'); const $dynamicWeatherToggle = $('#rpg-dynamic-weather-toggle-wrapper');
const $narratorToggle = $('#rpg-narrator-toggle-wrapper');
const $autoAvatarsToggle = $('#rpg-auto-avatars-toggle-wrapper');
// Show/hide individual toggles // Show/hide individual toggles
$htmlToggle.toggle(extensionSettings.showHtmlToggle); $htmlToggle.toggle(extensionSettings.showHtmlToggle);
$dialogueColoringToggle.toggle(extensionSettings.showDialogueColoringToggle);
$spotifyToggle.toggle(extensionSettings.showSpotifyToggle); $spotifyToggle.toggle(extensionSettings.showSpotifyToggle);
$snowflakesToggle.toggle(extensionSettings.showSnowflakesToggle);
$dynamicWeatherToggle.toggle(extensionSettings.showDynamicWeatherToggle); $dynamicWeatherToggle.toggle(extensionSettings.showDynamicWeatherToggle);
$narratorToggle.toggle(extensionSettings.showNarratorMode);
$autoAvatarsToggle.toggle(extensionSettings.showAutoAvatars);
// Hide entire row if all toggles are hidden // Hide entire row if all toggles are hidden
const anyVisible = extensionSettings.showHtmlToggle || const anyVisible = extensionSettings.showHtmlToggle ||
extensionSettings.showDialogueColoringToggle ||
extensionSettings.showSpotifyToggle || extensionSettings.showSpotifyToggle ||
extensionSettings.showSnowflakesToggle || extensionSettings.showDynamicWeatherToggle ||
extensionSettings.showDynamicWeatherToggle; extensionSettings.showNarratorMode ||
extensionSettings.showAutoAvatars;
$featuresRow.toggle(anyVisible); $featuresRow.toggle(anyVisible);
} }
+12
View File
@@ -348,6 +348,13 @@ function renderUserStatsTab() {
html += `<label for="rpg-show-rpg-attrs">${i18n.getTranslation('template.trackerEditorModal.userStatsTab.enableRpgAttributes')}</label>`; html += `<label for="rpg-show-rpg-attrs">${i18n.getTranslation('template.trackerEditorModal.userStatsTab.enableRpgAttributes')}</label>`;
html += '</div>'; html += '</div>';
// Show/hide level toggle
const showLevel = config.showLevel !== undefined ? config.showLevel : true;
html += '<div class="rpg-editor-toggle-row">';
html += `<input type="checkbox" id="rpg-show-level" ${showLevel ? 'checked' : ''}>`;
html += `<label for="rpg-show-level">Show Level</label>`;
html += '</div>';
// Always send attributes toggle // Always send attributes toggle
const alwaysSendAttributes = config.alwaysSendAttributes !== undefined ? config.alwaysSendAttributes : false; const alwaysSendAttributes = config.alwaysSendAttributes !== undefined ? config.alwaysSendAttributes : false;
html += '<div class="rpg-editor-toggle-row">'; html += '<div class="rpg-editor-toggle-row">';
@@ -510,6 +517,11 @@ function setupUserStatsListeners() {
extensionSettings.trackerConfig.userStats.showRPGAttributes = $(this).is(':checked'); extensionSettings.trackerConfig.userStats.showRPGAttributes = $(this).is(':checked');
}); });
// Show/hide level toggle
$('#rpg-show-level').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.showLevel = $(this).is(':checked');
});
// Always send attributes toggle // Always send attributes toggle
$('#rpg-always-send-attrs').off('change').on('change', function() { $('#rpg-always-send-attrs').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.alwaysSendAttributes = $(this).is(':checked'); extensionSettings.trackerConfig.userStats.alwaysSendAttributes = $(this).is(':checked');
+12 -1
View File
@@ -51,7 +51,18 @@ function parseWeatherType(weatherText) {
function getCurrentWeather() { function getCurrentWeather() {
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox || ''; const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox || '';
// Parse the Info Box data to find Weather field // Try to parse as JSON first (new format)
try {
const parsed = typeof infoBoxData === 'string' ? JSON.parse(infoBoxData) : infoBoxData;
if (parsed && parsed.weather) {
// Return the forecast text from the weather object
return parsed.weather.forecast || parsed.weather.emoji || null;
}
} catch (e) {
// Not JSON, try old text format
}
// Fallback: Parse the old text format to find Weather field
const lines = infoBoxData.split('\n'); const lines = infoBoxData.split('\n');
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
+433
View File
@@ -0,0 +1,433 @@
/**
* JSON Migration Module
* Migrates committed tracker data from v2 text format to v3 JSON format
*/
import { committedTrackerData, extensionSettings, updateCommittedTrackerData, updateExtensionSettings } from '../core/state.js';
import { saveSettings, saveChatData } from '../core/persistence.js';
/**
* Helper to separate emoji from text in a string
* @param {string} str - String potentially containing emoji followed by text
* @returns {{emoji: string, text: string}} Separated emoji and text
*/
function separateEmojiFromText(str) {
if (!str) return { emoji: '', text: '' };
str = str.trim();
// Regex to match emoji at the start
const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
const emojiMatch = str.match(emojiRegex);
if (emojiMatch) {
const emoji = emojiMatch[0];
let text = str.substring(emoji.length).trim();
// Remove leading comma or space
text = text.replace(/^[,\s]+/, '');
return { emoji, text };
}
// Check if there's a comma separator anyway
const commaParts = str.split(',');
if (commaParts.length >= 2) {
return {
emoji: commaParts[0].trim(),
text: commaParts.slice(1).join(',').trim()
};
}
// No clear separation - return original as text
return { emoji: '', text: str };
}
/**
* Parses item text to JSON format
* Handles "3x Item Name" or "Item Name" formats
* @param {string} itemsText - Comma-separated items string
* @returns {Array<{name: string, quantity?: number}>} Array of item objects
*/
function parseItemsToJSON(itemsText) {
if (!itemsText || itemsText.trim() === '' || itemsText.toLowerCase() === 'none') {
return [];
}
const items = itemsText.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
// Parse "3x Health Potion" format
const qtyMatch = item.match(/^(\d+)x\s*(.+)/i);
if (qtyMatch) {
return {
name: qtyMatch[2].trim(),
quantity: parseInt(qtyMatch[1])
};
}
return { name: item, quantity: 1 };
});
}
/**
* Migrates User Stats from v2 text format to v3 JSON format
* @param {string} textData - V2 text format user stats
* @returns {object} V3 JSON format user stats
*/
export function migrateUserStatsToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3,
stats: [],
status: {},
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: null,
optional: []
}
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```')) continue;
// Parse "- StatName: X%" format
const statMatch = trimmed.match(/^-\s*([^:]+):\s*(\d+)%/);
if (statMatch) {
const name = statMatch[1].trim();
const id = name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
result.stats.push({
id: id,
name: name,
value: parseInt(statMatch[2])
});
continue;
}
// Parse "Status: emoji, text" or "Status: text" format
const statusMatch = trimmed.match(/^Status:\s*(.+)/i);
if (statusMatch) {
const { emoji, text } = separateEmojiFromText(statusMatch[1]);
if (emoji) result.status.mood = emoji;
if (text) result.status.conditions = text;
continue;
}
// Parse "Skills: skill1, skill2" format
const skillsMatch = trimmed.match(/^Skills:\s*(.+)/i);
if (skillsMatch) {
const skillsText = skillsMatch[1].trim();
if (skillsText && skillsText.toLowerCase() !== 'none') {
const skills = skillsText.split(',').map(s => s.trim()).filter(s => s);
result.skills = skills.map(name => ({ name }));
}
continue;
}
// Parse inventory lines
const onPersonMatch = trimmed.match(/^On Person:\s*(.+)/i);
if (onPersonMatch) {
result.inventory.onPerson = parseItemsToJSON(onPersonMatch[1]);
continue;
}
const clothingMatch = trimmed.match(/^Clothing:\s*(.+)/i);
if (clothingMatch) {
result.inventory.clothing = parseItemsToJSON(clothingMatch[1]);
continue;
}
const storedMatch = trimmed.match(/^Stored\s*-\s*([^:]+):\s*(.+)/i);
if (storedMatch) {
const location = storedMatch[1].trim();
result.inventory.stored[location] = parseItemsToJSON(storedMatch[2]);
continue;
}
const assetsMatch = trimmed.match(/^Assets:\s*(.+)/i);
if (assetsMatch) {
const assetsText = assetsMatch[1].trim();
if (assetsText && assetsText.toLowerCase() !== 'none') {
result.inventory.assets = assetsText.split(',').map(s => s.trim()).filter(s => s).map(name => ({ name }));
}
continue;
}
// Parse quest lines
const mainQuestMatch = trimmed.match(/^Main Quests?:\s*(.+)/i);
if (mainQuestMatch) {
const questText = mainQuestMatch[1].trim();
if (questText && questText.toLowerCase() !== 'none') {
result.quests.main = { title: questText };
}
continue;
}
const optionalQuestsMatch = trimmed.match(/^Optional Quests?:\s*(.+)/i);
if (optionalQuestsMatch) {
const questsText = optionalQuestsMatch[1].trim();
if (questsText && questsText.toLowerCase() !== 'none') {
const quests = questsText.split(',').map(s => s.trim()).filter(s => s);
result.quests.optional = quests.map(title => ({ title }));
}
continue;
}
}
return result;
}
/**
* Migrates Info Box from v2 text format to v3 JSON format
* @param {string} textData - V2 text format info box
* @returns {object} V3 JSON format info box
*/
export function migrateInfoBoxToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const lines = textData.split('\n');
const result = {
version: 3
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === '---' || trimmed.startsWith('```') || trimmed.toLowerCase() === 'info box') continue;
// Parse "Date: value" format
const dateMatch = trimmed.match(/^Date:\s*(.+)/i);
if (dateMatch) {
result.date = { value: dateMatch[1].trim() };
continue;
}
// Parse "Weather: emoji, text" or "Weather: text" format
const weatherMatch = trimmed.match(/^Weather:\s*(.+)/i);
if (weatherMatch) {
const { emoji, text } = separateEmojiFromText(weatherMatch[1]);
result.weather = {
emoji: emoji || '',
forecast: text || weatherMatch[1].trim()
};
continue;
}
// Parse "Temperature: X°C" or "Temperature: X°F" format
const tempMatch = trimmed.match(/^Temperature:\s*(\d+)\s*°?([CF])?/i);
if (tempMatch) {
result.temperature = {
value: parseInt(tempMatch[1]),
unit: tempMatch[2] ? tempMatch[2].toUpperCase() : 'C'
};
continue;
}
// Parse "Time: start → end" format
const timeMatch = trimmed.match(/^Time:\s*(.+?)\s*→\s*(.+)/i);
if (timeMatch) {
result.time = {
start: timeMatch[1].trim(),
end: timeMatch[2].trim()
};
continue;
}
// Parse "Location: value" format
const locationMatch = trimmed.match(/^Location:\s*(.+)/i);
if (locationMatch) {
result.location = { value: locationMatch[1].trim() };
continue;
}
// Parse "Recent Events: event1, event2, event3" format
const eventsMatch = trimmed.match(/^Recent Events:\s*(.+)/i);
if (eventsMatch) {
const eventsText = eventsMatch[1].trim();
if (eventsText && eventsText.toLowerCase() !== 'none') {
result.recentEvents = eventsText.split(',').map(s => s.trim()).filter(s => s);
}
continue;
}
}
return result;
}
/**
* Migrates Present Characters from v2 text format to v3 JSON format
* @param {string} textData - V2 text format present characters
* @returns {object} V3 JSON format present characters
*/
export function migrateCharactersToJSON(textData) {
if (!textData || typeof textData !== 'string') {
return null;
}
const result = {
version: 3,
characters: []
};
// Split by character blocks (marked by "- Name")
const blocks = ('\n' + textData).split(/\n-\s+/);
for (const block of blocks) {
if (!block.trim()) continue;
const lines = block.trim().split('\n');
if (lines.length === 0) continue;
const character = {
name: lines[0].trim()
};
// Parse subsequent lines for this character
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Parse "Details: emoji | field1 | field2" format
const detailsMatch = line.match(/^Details:\s*(.+)/i);
if (detailsMatch) {
const detailsText = detailsMatch[1].trim();
const parts = detailsText.split('|').map(s => s.trim());
const { emoji } = separateEmojiFromText(parts[0] || '');
if (emoji) character.emoji = emoji;
character.details = {};
for (let j = 1; j < parts.length; j++) {
const fieldName = `field${j}`;
character.details[fieldName] = parts[j];
}
continue;
}
// Parse "Relationship: status" format
const relationshipMatch = line.match(/^Relationship:\s*(.+)/i);
if (relationshipMatch) {
character.relationship = { status: relationshipMatch[1].trim() };
continue;
}
// Parse "Stats: stat1: X% | stat2: Y%" format
const statsMatch = line.match(/^Stats:\s*(.+)/i);
if (statsMatch) {
const statsText = statsMatch[1].trim();
const statParts = statsText.split('|').map(s => s.trim());
character.stats = [];
for (const statPart of statParts) {
const statValueMatch = statPart.match(/^([^:]+):\s*(\d+)%/);
if (statValueMatch) {
character.stats.push({
name: statValueMatch[1].trim(),
value: parseInt(statValueMatch[2])
});
}
}
continue;
}
// Parse "Thoughts: content" format
const thoughtsMatch = line.match(/^Thoughts:\s*(.+)/i);
if (thoughtsMatch) {
character.thoughts = { content: thoughtsMatch[1].trim() };
continue;
}
}
result.characters.push(character);
}
return result;
}
/**
* Main migration function - migrates all committed tracker data to v3 JSON format
* @returns {Promise<void>}
*/
export async function migrateToV3JSON() {
console.log('[RPG Migration] Starting migration to v3 JSON format...');
const migrated = {
userStats: null,
infoBox: null,
characterThoughts: null
};
// Migrate User Stats
if (committedTrackerData.userStats && typeof committedTrackerData.userStats === 'string') {
console.log('[RPG Migration] Migrating User Stats...');
migrated.userStats = migrateUserStatsToJSON(committedTrackerData.userStats);
if (migrated.userStats) {
console.log('[RPG Migration] ✓ User Stats migrated');
}
}
// Migrate Info Box
if (committedTrackerData.infoBox && typeof committedTrackerData.infoBox === 'string') {
console.log('[RPG Migration] Migrating Info Box...');
migrated.infoBox = migrateInfoBoxToJSON(committedTrackerData.infoBox);
if (migrated.infoBox) {
console.log('[RPG Migration] ✓ Info Box migrated');
}
}
// Migrate Present Characters
if (committedTrackerData.characterThoughts && typeof committedTrackerData.characterThoughts === 'string') {
console.log('[RPG Migration] Migrating Present Characters...');
migrated.characterThoughts = migrateCharactersToJSON(committedTrackerData.characterThoughts);
if (migrated.characterThoughts) {
console.log('[RPG Migration] ✓ Present Characters migrated');
}
}
// Update committed data
updateCommittedTrackerData(migrated);
// Initialize lockedItems if not present
if (!extensionSettings.lockedItems) {
console.log('[RPG Migration] Initializing lockedItems structure...');
updateExtensionSettings({
lockedItems: {
stats: [],
skills: [],
inventory: {
onPerson: [],
clothing: [],
stored: {},
assets: []
},
quests: {
main: false,
optional: []
},
infoBox: {
date: false,
weather: false,
temperature: false,
time: false,
location: false,
recentEvents: false
},
characters: {}
}
});
}
// Save migrated data
await saveChatData();
await saveSettings();
console.log('[RPG Migration] ✅ Migration to v3 JSON format complete');
}
+220
View File
@@ -0,0 +1,220 @@
/**
* JSON Repair Utilities
* Handles parsing and repairing malformed JSON from AI responses
*/
/**
* Repairs malformed JSON from AI responses
* Handles common AI mistakes like trailing commas, missing commas, wrong quotes, etc.
*
* @param {string} jsonString - Potentially malformed JSON string
* @returns {object|null} Repaired JSON object or null if repair fails
*/
export function repairJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input:', typeof jsonString);
return null;
}
let cleaned = jsonString.trim();
// Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, '');
// Remove thinking tags (model's internal reasoning)
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
// Fix common JSON errors:
// 1. Trailing commas before closing brackets
cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
// 2. Missing commas between properties - DISABLED because it corrupts valid JSON
// Modern AI models send properly formatted JSON, so this aggressive repair is not needed
// cleaned = cleaned.replace(/("\s*:\s*(?:"[^"]*"|[^,}\]]+))(\s+")/g, '$1,$2');
// 3. Single quotes to double quotes - DISABLED because it corrupts apostrophes in text
// Apostrophes in strings like "Zandik's Office" would become "Zandik"s Office" (invalid JSON)
// Modern AI models already use double quotes for JSON strings
// cleaned = cleaned.replace(/'/g, '"');
// 4. Unquoted keys - DISABLED because it corrupts valid JSON string values
// The AI models already send properly quoted JSON, so this is not needed
// cleaned = cleaned.replace(/(\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
// 5. Remove JavaScript comments
cleaned = cleaned.replace(/\/\/.*$/gm, '');
cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, '');
// Attempt 1: Standard JSON.parse
try {
return JSON.parse(cleaned);
} catch (e) {
}
// Attempt 2: Extract JSON object between first { and last }
const objectMatch = cleaned.match(/\{[\s\S]*\}/);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 3: Try to extract JSON array between first [ and last ]
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0]);
} catch (e) {
// Silent fail, try next method
}
}
// Attempt 4: Use Function constructor (safer than eval, still controlled)
// Only as last resort for trusted AI output
try {
const fn = new Function(`"use strict"; return (${cleaned});`);
const result = fn();
// Validate it's actually an object or array
if (result && (typeof result === 'object')) {
console.log('[RPG JSON Repair] ✓ Repaired using Function constructor');
return result;
}
} catch (e) {
console.error('[RPG JSON Repair] ✗ All repair attempts failed:', e.message);
}
return null;
}
/**
* Validates JSON structure matches expected schema for a tracker type
*
* @param {object} data - Parsed JSON data to validate
* @param {string} type - Type of tracker ('userStats', 'infoBox', 'characters')
* @returns {boolean} True if valid, false otherwise
*/
export function validateJSONSchema(data, type) {
if (!data || typeof data !== 'object') {
return false;
}
try {
switch (type) {
case 'userStats':
return Array.isArray(data.stats) &&
data.stats.every(s =>
s &&
typeof s === 'object' &&
s.id &&
s.name &&
typeof s.value === 'number'
);
case 'infoBox':
return (data.date || data.weather || data.time || data.location || data.temperature || data.recentEvents);
case 'characters':
return Array.isArray(data.characters) &&
data.characters.every(c => c && c.name);
default:
console.warn('[RPG JSON Validation] Unknown tracker type:', type);
return false;
}
} catch (e) {
console.error('[RPG JSON Validation] Error during validation:', e);
return false;
}
}
/**
* Extracts JSON from text that may contain other content
* Looks for JSON blocks within ```json fences or standalone JSON objects
*
* @param {string} text - Text potentially containing JSON
* @returns {string|null} Extracted JSON string or null
*/
export function extractJSONFromText(text) {
if (!text || typeof text !== 'string') {
return null;
}
// Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
if (fenceMatch && fenceMatch[1]) {
return fenceMatch[1].trim();
}
// Try to extract from ``` code fence (without json label)
const genericFenceMatch = text.match(/```\s*([\s\S]*?)```/);
if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
return content;
}
}
// Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch) {
return objectMatch[0];
}
// Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
return arrayMatch[0];
}
return null;
}
/**
* Safely parses JSON with automatic repair attempts
* Combines extraction, repair, and validation in one call
*
* @param {string} text - Text containing JSON (with or without code fences)
* @param {string} expectedType - Expected tracker type for validation ('userStats', 'infoBox', 'characters')
* @returns {{data: object|null, success: boolean, error: string|null}} Result object
*/
export function safeParseJSON(text, expectedType = null) {
const result = {
data: null,
success: false,
error: null
};
// Extract JSON from text
const jsonString = extractJSONFromText(text);
if (!jsonString) {
result.error = 'No JSON found in text';
return result;
}
// Attempt to repair and parse
const parsed = repairJSON(jsonString);
if (!parsed) {
result.error = 'Failed to parse JSON after repair attempts';
return result;
}
// Validate schema if type specified
if (expectedType) {
const valid = validateJSONSchema(parsed, expectedType);
if (!valid) {
result.error = `JSON does not match expected schema for type: ${expectedType}`;
result.data = parsed; // Return data anyway, might be partially useful
return result;
}
}
result.data = parsed;
result.success = true;
return result;
}
+20 -3
View File
@@ -28,7 +28,24 @@ const DEFAULT_INVENTORY_V2 = {
* @returns {MigrationResult} Migration result with v2 inventory and metadata * @returns {MigrationResult} Migration result with v2 inventory and metadata
*/ */
export function migrateInventory(inventory) { export function migrateInventory(inventory) {
// Case 1: Already v2 format (has version property and is an object) // Case 1: v2 format missing version property (parser output)
// Parser returns v2 structure but without the version tag
if (inventory && typeof inventory === 'object' &&
'onPerson' in inventory && 'clothing' in inventory &&
'stored' in inventory && 'assets' in inventory &&
!('version' in inventory)) {
// console.log('[RPG Companion Migration] v2 inventory missing version tag, adding it');
return {
inventory: {
version: 2,
...inventory
},
migrated: true,
source: 'parser-output'
};
}
// Case 2: Already v2 format (has version property and is an object)
if (inventory && typeof inventory === 'object' && inventory.version === 2) { if (inventory && typeof inventory === 'object' && inventory.version === 2) {
// Check if clothing field exists (v2.1 upgrade) // Check if clothing field exists (v2.1 upgrade)
if (!inventory.hasOwnProperty('clothing')) { if (!inventory.hasOwnProperty('clothing')) {
@@ -49,7 +66,7 @@ export function migrateInventory(inventory) {
}; };
} }
// Case 2: null or undefined → use defaults // Case 3: null or undefined → use defaults
if (inventory === null || inventory === undefined) { if (inventory === null || inventory === undefined) {
// console.log('[RPG Companion Migration] Inventory is null/undefined, using defaults'); // console.log('[RPG Companion Migration] Inventory is null/undefined, using defaults');
return { return {
@@ -59,7 +76,7 @@ export function migrateInventory(inventory) {
}; };
} }
// Case 3: v1 string format → migrate to v2 // Case 4: v1 string format → migrate to v2
if (typeof inventory === 'string') { if (typeof inventory === 'string') {
// Check if it's an empty/default string // Check if it's an empty/default string
const trimmed = inventory.trim(); const trimmed = inventory.trim();
+1036 -345
View File
File diff suppressed because it is too large Load Diff
+282 -168
View File
@@ -81,6 +81,15 @@
</label> </label>
</div> </div>
<!-- Dialogue Coloring Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-dialogue-coloring-toggle-wrapper">
<label class="rpg-toggle-label" title="Colored Dialogues">
<input type="checkbox" id="rpg-toggle-dialogue-coloring">
<i class="fa-solid fa-palette"></i>
<span class="rpg-toggle-text" data-i18n-key="template.mainPanel.coloredDialogues">Colored Dialogues</span>
</label>
</div>
<!-- Spotify Music Toggle --> <!-- Spotify Music Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-spotify-toggle-wrapper"> <div class="rpg-toggle-container rpg-feature-col" id="rpg-spotify-toggle-wrapper">
<label class="rpg-toggle-label" title="Spotify Music"> <label class="rpg-toggle-label" title="Spotify Music">
@@ -90,15 +99,6 @@
</label> </label>
</div> </div>
<!-- Snowflakes Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-snowflakes-toggle-wrapper">
<label class="rpg-toggle-label" title="Snowflakes Effect">
<input type="checkbox" id="rpg-toggle-snowflakes">
<i class="fa-solid fa-snowflake"></i>
<span class="rpg-toggle-text" data-i18n-key="template.mainPanel.snowflakesEffect">Snowflakes Effect</span>
</label>
</div>
<!-- Dynamic Weather Toggle --> <!-- Dynamic Weather Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-dynamic-weather-toggle-wrapper"> <div class="rpg-toggle-container rpg-feature-col" id="rpg-dynamic-weather-toggle-wrapper">
<label class="rpg-toggle-label" title="Dynamic Weather Effects"> <label class="rpg-toggle-label" title="Dynamic Weather Effects">
@@ -107,7 +107,22 @@
<span class="rpg-toggle-text" data-i18n-key="template.mainPanel.dynamicWeatherEffects">Dynamic Weather</span> <span class="rpg-toggle-text" data-i18n-key="template.mainPanel.dynamicWeatherEffects">Dynamic Weather</span>
</label> </label>
</div> </div>
</div> <!-- Narrator Mode Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-narrator-toggle-wrapper">
<label class="rpg-toggle-label" title="Narrator Mode">
<input type="checkbox" id="rpg-toggle-narrator">
<i class="fa-solid fa-book-open"></i>
<span class="rpg-toggle-text" data-i18n-key="template.mainPanel.narratorMode">Narrator Mode</span>
</label>
</div>
<!-- Auto-generate Avatars Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-auto-avatars-toggle-wrapper">
<label class="rpg-toggle-label" title="Auto-generate Avatars">
<input type="checkbox" id="rpg-toggle-auto-avatars-panel">
<i class="fa-solid fa-user-plus"></i>
<span class="rpg-toggle-text" data-i18n-key="template.mainPanel.autoAvatars">Auto Avatars</span>
</label>
</div> </div>
<!-- Manual Update Button --> <!-- Manual Update Button -->
<button id="rpg-manual-update" class="rpg-btn-primary rpg-manual-update-btn"> <button id="rpg-manual-update" class="rpg-btn-primary rpg-manual-update-btn">
@@ -126,15 +141,6 @@
data-i18n-key="template.mainPanel.settingsButton">Settings</span> data-i18n-key="template.mainPanel.settingsButton">Settings</span>
</button> </button>
</div> </div>
<!-- Holiday Promotion -->
<div class="rpg-holiday-promo" id="rpg-holiday-promo" style="text-align: center; padding: 12px 10px; margin-top: 8px; font-size: 11px; opacity: 0.85; position: relative; line-height: 1.5;">
<button id="rpg-dismiss-promo" style="position: absolute; top: 4px; right: 4px; background: none; border: none; color: currentColor; opacity: 0.6; cursor: pointer; padding: 2px 6px; font-size: 14px; line-height: 1;" title="Dismiss permanently"></button>
<div style="margin-bottom: 4px;">Happy Holidays & Happy New Year!</div>
<a href="https://www.electronhub.ai/" target="_blank" style="color: inherit; text-decoration: none; border-bottom: 1px dotted currentColor; display: inline-block;">
🎁 15% OFF for Electron Hub subscriptions with <strong>2026WITHMARINARA</strong> 🎁
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -201,7 +207,7 @@
Color (Low):</label> Color (Low):</label>
<input type="color" id="rpg-stat-bar-color-low" value="#cc3333" /> <input type="color" id="rpg-stat-bar-color-low" value="#cc3333" />
<small data-i18n-key="template.settingsModal.theme.statBarLowNote">Color when stats are at <small data-i18n-key="template.settingsModal.theme.statBarLowNote">Color when stats are at
0%</small> 0%.</small>
</div> </div>
<div class="rpg-setting-row"> <div class="rpg-setting-row">
@@ -209,7 +215,7 @@
Bar Color (High):</label> Bar Color (High):</label>
<input type="color" id="rpg-stat-bar-color-high" value="#33cc66" /> <input type="color" id="rpg-stat-bar-color-high" value="#33cc66" />
<small data-i18n-key="template.settingsModal.theme.statBarHighNote">Color when stats are at <small data-i18n-key="template.settingsModal.theme.statBarHighNote">Color when stats are at
100%</small> 100%.</small>
</div> </div>
</div> </div>
@@ -219,7 +225,7 @@
<small class="notes" style="display: block; margin-bottom: 10px;" <small class="notes" style="display: block; margin-bottom: 10px;"
data-i18n-key="template.settingsModal.displayNote"> data-i18n-key="template.settingsModal.displayNote">
<i class="fa-solid fa-info-circle" aria-hidden="true"></i> Use the Extensions tab to enable/disable <i class="fa-solid fa-info-circle" aria-hidden="true"></i> Use the Extensions tab to enable/disable
the RPG Companion extension. the RPG Companion extension entirely
</small> </small>
<div class="rpg-setting-row"> <div class="rpg-setting-row">
@@ -237,84 +243,145 @@
<input type="checkbox" id="rpg-toggle-user-stats" /> <input type="checkbox" id="rpg-toggle-user-stats" />
<span data-i18n-key="template.settingsModal.display.showUserStats">Show User Stats</span> <span data-i18n-key="template.settingsModal.display.showUserStats">Show User Stats</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showUserStatsNote">
Enable User Stats that track your persona's statistics, mood, attributes, skills, etc.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-info-box" /> <input type="checkbox" id="rpg-toggle-info-box" />
<span data-i18n-key="template.settingsModal.display.showInfoBox">Show Info Box</span> <span data-i18n-key="template.settingsModal.display.showInfoBox">Show Info Box</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showInfoBoxNote">
Display location, time, weather, and recent events.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-thoughts" /> <input type="checkbox" id="rpg-toggle-thoughts" />
<span data-i18n-key="template.settingsModal.display.showPresentCharacters">Show Present <span data-i18n-key="template.settingsModal.display.showPresentCharacters">Show Present
Characters</span> Characters</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showPresentCharactersNote">
Display character portraits with their current thoughts and status.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-thoughts-in-chat" />
<span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showThoughtsInChatNote">
Display character thoughts as overlay bubbles next to their messages.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-inventory" /> <input type="checkbox" id="rpg-toggle-inventory" />
<span data-i18n-key="template.settingsModal.display.showInventory">Show Inventory</span> <span data-i18n-key="template.settingsModal.display.showInventory">Show Inventory</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showInventoryNote">
Track items carried, clothing worn, stored items, and assets.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-quests" /> <input type="checkbox" id="rpg-toggle-quests" />
<span data-i18n-key="template.settingsModal.display.showQuests">Show Quests</span> <span data-i18n-key="template.settingsModal.display.showQuests">Show Quests</span>
</label> </label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-thoughts-in-chat" />
<span data-i18n-key="template.settingsModal.display.showThoughtsInChat">Show Thoughts in Chat</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" <small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showThoughtsInChatNote"> data-i18n-key="template.settingsModal.display.showQuestsNote">
Display character thoughts as overlay bubbles next to their messages Manage main and optional quests with objectives.
</small> </small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-always-show-bubble" /> <input type="checkbox" id="rpg-toggle-lock-icons" />
<span data-i18n-key="template.settingsModal.display.alwaysShowThoughtBubble">Always Show Thought <span data-i18n-key="template.settingsModal.display.showLockIcons">Show Locking/Unlocking Trackers</span>
Bubble</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" <small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.alwaysShowThoughtBubbleNote"> data-i18n-key="template.settingsModal.display.showLockIconsNote">
Auto-expand thought bubble without clicking the icon first Display lock/unlock icons on tracker items to prevent AI from modifying them.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-animations" />
<span data-i18n-key="template.settingsModal.display.enableAnimations">Enable Animations</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.enableAnimationsNote">
Smooth transitions for stats, content updates, and dice rolls
</small> </small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-html-toggle" /> <input type="checkbox" id="rpg-toggle-show-html-toggle" />
<span data-i18n-key="template.settingsModal.display.showImmersiveHtmlToggle">Show Immersive HTML</span> <span data-i18n-key="template.settingsModal.display.showImmersiveHtmlToggle">Show Immersive HTML</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showImmersiveHtmlToggleNote">
Display a toggle button to enable/disable HTML formatting in messages.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-dialogue-coloring-toggle" />
<span data-i18n-key="template.settingsModal.display.showDialogueColoringToggle">Show Colored Dialogues</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showDialogueColoringToggleNote">
Display a toggle button to enable/disable colored dialogue formatting.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-spotify-toggle" /> <input type="checkbox" id="rpg-toggle-show-spotify-toggle" />
<span data-i18n-key="template.settingsModal.display.showSpotifyMusicToggle">Show Spotify Music</span> <span data-i18n-key="template.settingsModal.display.showSpotifyMusicToggle">Show Spotify Music</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
<label class="checkbox_label"> data-i18n-key="template.settingsModal.display.showSpotifyMusicToggleNote">
<input type="checkbox" id="rpg-toggle-show-snowflakes-toggle" /> Display Spotify music player with AI-suggested scene-appropriate tracks.
<span data-i18n-key="template.settingsModal.display.showSnowflakesToggle">Show Snowflakes Effect</span> </small>
</label>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-dynamic-weather-toggle" /> <input type="checkbox" id="rpg-toggle-show-dynamic-weather-toggle" />
<span data-i18n-key="template.settingsModal.display.showDynamicWeatherToggle">Show Dynamic Weather Effects</span> <span data-i18n-key="template.settingsModal.display.showDynamicWeatherToggle">Show Dynamic Weather Effects</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showDynamicWeatherToggleNote">
Display a toggle button to enable/disable animated weather effects.
</small>
<label class="checkbox_label"> <label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-plot-buttons" /> <input type="checkbox" id="rpg-toggle-show-narrator-mode" />
<span data-i18n-key="template.settingsModal.display.showPlotProgressionButtons">Show Plot <span data-i18n-key="template.settingsModal.display.showNarratorMode">Show Narrator Mode</span>
Progression Buttons</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" <small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showPlotProgressionButtonsNote"> data-i18n-key="template.settingsModal.display.showNarratorModeNote">
Display buttons above chat input for plot progression prompts Display a toggle button to enable/disable narrator mode (infer characters from context).
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-auto-avatars" />
<span data-i18n-key="template.settingsModal.display.showAutoAvatars">Show Auto-generate Avatars</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showAutoAvatarsNote">
Display a toggle button to automatically generate avatars for characters without images.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-randomized-plot" />
<span data-i18n-key="template.settingsModal.display.showRandomizedPlot">Show Randomized Plot Progression</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showRandomizedPlotNote">
Display button for AI-generated random plot progression prompts.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-natural-plot" />
<span data-i18n-key="template.settingsModal.display.showNaturalPlot">Show Natural Plot Progression</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showNaturalPlotNote">
Display button for context-aware narrative continuation prompts.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-encounters" />
<span data-i18n-key="template.settingsModal.display.showStartEncounter">Show Start Encounter</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showStartEncounterNote">
Display button to initiate interactive combat encounters.
</small> </small>
<label class="checkbox_label"> <label class="checkbox_label">
@@ -323,58 +390,9 @@
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" <small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showDiceDisplayNote"> data-i18n-key="template.settingsModal.display.showDiceDisplayNote">
Display the "Last Roll" indicator in the panel. Display the "Last Roll" indicator in the panel
</small> </small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-auto-avatars" />
<span data-i18n-key="template.settingsModal.display.autoGenerateAvatars">Auto-generate Missing
Avatars</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.autoGenerateAvatarsNote">
Automatically generate avatars for characters without custom images using the Image Generation
Plugin
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-debug-mode" />
<span data-i18n-key="template.settingsModal.display.enableDebugMode">Enable Debug Mode</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.enableDebugModeNote">
Shows parser logs in a mobile-friendly UI panel. Useful for troubleshooting. Look for the red bug
button.
</small>
</div>
<div class="rpg-settings-group">
<h4><i class="fa-solid fa-swords" aria-hidden="true"></i> Combat Encounters</h4>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-encounters" />
<span>Enable Combat Encounters</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;">
Show the "Start Encounter" button above chat input for interactive combat
</small>
<div class="rpg-setting-row" style="margin-top: 12px;">
<label for="rpg-encounter-history-depth">Chat History Depth:</label>
<input type="number" id="rpg-encounter-history-depth" min="1" max="20" value="8"
class="rpg-input" />
<small>Number of recent messages to include in combat initialization</small>
</div>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-autosave-logs" />
<span>Auto-save Combat Logs</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;">
Save detailed combat logs to file for future reference and analysis
</small>
</div> </div>
<div class="rpg-settings-group"> <div class="rpg-settings-group">
@@ -468,44 +486,52 @@
</div> </div>
</div> </div>
<div class="rpg-setting-row">
<label for="rpg-update-depth"
data-i18n-key="template.settingsModal.advanced.contextMessages">Context Messages:</label>
<input type="number" id="rpg-update-depth" min="1" max="20" value="4" class="rpg-input" />
<small data-i18n-key="template.settingsModal.advanced.contextMessagesNote">Number of recent messages
to include (Separate mode only)</small>
</div>
<div class="rpg-setting-row">
<label for="rpg-memory-messages"
data-i18n-key="template.settingsModal.advanced.memoryBatchSize">Memory Batch Size:</label>
<input type="number" id="rpg-memory-messages" min="4" max="50" value="16" class="rpg-input" />
<small data-i18n-key="template.settingsModal.advanced.memoryBatchSizeNote">Number of messages to
process per batch in Memory Recollection</small>
</div>
<div id="rpg-separate-mode-settings"> <div id="rpg-separate-mode-settings">
<label class="checkbox_label"> <div class="rpg-setting-row">
<input type="checkbox" id="rpg-use-separate-preset" /> <label for="rpg-update-depth"
<span data-i18n-key="template.settingsModal.advanced.useSeparatePreset">Use model connected to RPG data-i18n-key="template.settingsModal.advanced.contextMessages">Context Messages:</label>
Companion Trackers preset</span> <input type="number" id="rpg-update-depth" min="1" max="20" value="4" class="rpg-input" />
</label> <small data-i18n-key="template.settingsModal.advanced.contextMessagesNote">Number of recent messages
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;" to include (Separate mode only)</small>
data-i18n-key="template.settingsModal.advanced.useSeparatePresetNote"> </div>
Separate mode only. When enabled, tracker generation will use the model from the "RPG Companion
Trackers" preset instead of your main API model. The preset will be switched automatically during
generation and restored afterward. Select the desired model in that preset and make sure the "Bind
presets to API connections" toggle is on (next to the import/export preset buttons).
</small>
</div> </div>
<label class="checkbox_label" id="rpg-auto-update-container"> <label class="checkbox_label" id="rpg-auto-update-container" style="margin-top: 16px;">
<input type="checkbox" id="rpg-toggle-auto-update" /> <input type="checkbox" id="rpg-toggle-auto-update" />
<span data-i18n-key="template.settingsModal.display.toggleAutoUpdate">Auto-update after <span data-i18n-key="template.settingsModal.display.toggleAutoUpdate">Auto-update after
messages</span> messages</span>
</label> </label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"> <small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
Automatically refresh RPG info after each message. Only available in Separate Generation mode. data-i18n-key="template.settingsModal.display.toggleAutoUpdateNote">
Automatically refresh RPG info after each message.
</small>
<label class="checkbox_label" style="margin-top: 16px;">
<input type="checkbox" id="rpg-save-tracker-history" />
<span data-i18n-key="template.settingsModal.advanced.saveTrackerHistory">Save Tracker History in
Chat</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.advanced.saveTrackerHistoryNote">
When enabled, tracker data is saved in chat history for each message. In Together mode, trackers
appear in &lt;trackers&gt; XML tags (hidden from display). In Separate mode, tracker data is stored
in message metadata. When disabled, only the most recent trackers are kept.
</small>
<div class="rpg-setting-row">
<label for="rpg-encounter-history-depth" data-i18n-key="template.settingsModal.advanced.encounterHistoryDepth">Chat History Depth For Encounters:</label>
<input type="number" id="rpg-encounter-history-depth" min="1" max="20" value="8"
class="rpg-input" />
<small data-i18n-key="template.settingsModal.advanced.encounterHistoryDepthNote">Number of recent messages to include in combat initialization.</small>
</div>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-autosave-logs" />
<span data-i18n-key="template.settingsModal.advanced.autoSaveCombatLogs">Auto-save Combat Logs</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.advanced.autoSaveCombatLogsNote">
Save detailed combat logs to file for future reference and analysis.
</small> </small>
<div class="rpg-setting-row" style="margin-top: 16px;"> <div class="rpg-setting-row" style="margin-top: 16px;">
@@ -530,35 +556,13 @@
when using GuidedGenerations or similar extensions. when using GuidedGenerations or similar extensions.
</small> </small>
<label class="checkbox_label" style="margin-top: 16px;">
<input type="checkbox" id="rpg-toggle-narrator-mode" />
<span data-i18n-key="template.settingsModal.display.narratorMode">Narrator Mode</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.narratorModeNote">
Use character card as narrator. Infer characters from context instead of using fixed character
references.
</small>
<label class="checkbox_label" style="margin-top: 16px;">
<input type="checkbox" id="rpg-save-tracker-history" />
<span data-i18n-key="template.settingsModal.advanced.saveTrackerHistory">Save Tracker History in
Chat</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.advanced.saveTrackerHistoryNote">
When enabled, tracker data is saved in chat history for each message. In Together mode, trackers
appear in &lt;trackers&gt; XML tags (hidden from display). In Separate mode, tracker data is stored
in message metadata. When disabled, only the most recent trackers are kept.
</small>
<!-- Customize Prompts Button --> <!-- Customize Prompts Button -->
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rpg-border);"> <div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rpg-border);">
<button id="rpg-open-prompts-editor" class="menu_button" style="width: 100%;"> <button id="rpg-open-prompts-editor" class="rpg-btn-customize-prompts">
<i class="fa-solid fa-file-lines" aria-hidden="true"></i> <span>Customize Prompts</span> <i class="fa-solid fa-file-lines" aria-hidden="true"></i> <span>Customize Prompts</span>
</button> </button>
<small style="display: block; margin-top: 8px; color: #888; font-size: 11px;"> <small style="display: block; margin-top: 8px; color: #888; font-size: 11px;">
Edit all AI prompts used for generation, plot progression, and combat encounters Edit all AI prompts used for generation, plot progression, and combat encounters.
</small> </small>
</div> </div>
@@ -571,7 +575,7 @@
</button> </button>
<small style="display: block; margin-top: 8px; color: #888; font-size: 11px;" <small style="display: block; margin-top: 8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.advanced.resetFabPositionsNote"> data-i18n-key="template.settingsModal.advanced.resetFabPositionsNote">
Resets all floating action buttons (toggle, refresh, debug) to default top-left positions. Resets all floating action buttons (toggle, refresh) to default top-left positions.
Useful if buttons are off-screen. Useful if buttons are off-screen.
</small> </small>
</div> </div>
@@ -582,6 +586,10 @@
<i class="fa-solid fa-trash" aria-hidden="true"></i> <span <i class="fa-solid fa-trash" aria-hidden="true"></i> <span
data-i18n-key="template.settingsModal.advanced.clearCache">Clear Extension Cache</span> data-i18n-key="template.settingsModal.advanced.clearCache">Clear Extension Cache</span>
</button> </button>
<small style="display: block; margin-top: 8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.advanced.clearCacheNote">
Clears all cached data including tracker history and temporary files.
</small>
</div> </div>
</div> </div>
</div> </div>
@@ -726,7 +734,7 @@
<div class="rpg-settings-popup-body"> <div class="rpg-settings-popup-body">
<small class="notes" style="display: block; margin-bottom: 16px;"> <small class="notes" style="display: block; margin-bottom: 16px;">
<i class="fa-solid fa-info-circle"></i> Customize the AI prompts used throughout the extension. Leave fields empty to use defaults. Customize the AI prompts used throughout the extension. Leave fields empty to use defaults.
</small> </small>
<!-- HTML Prompt --> <!-- HTML Prompt -->
@@ -739,7 +747,21 @@
</small> </small>
<textarea id="rpg-prompt-html" class="rpg-prompt-textarea" rows="4"></textarea> <textarea id="rpg-prompt-html" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="html" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="html" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button>
</div>
<!-- Dialogue Coloring Prompt -->
<div class="rpg-prompt-editor-section">
<label for="rpg-prompt-dialogue-coloring" style="display: block; margin-bottom: 8px; font-weight: 600;">
<i class="fa-solid fa-palette"></i> Dialogue Coloring Prompt
</label>
<small style="display: block; margin-bottom: 8px; color: #888; font-size: 11px;">
Injected when "Enable Dialogue Coloring" is enabled. Affects all generation modes.
</small>
<textarea id="rpg-prompt-dialogue-coloring" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="dialogue-coloring" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
@@ -753,7 +775,21 @@
</small> </small>
<textarea id="rpg-prompt-spotify" class="rpg-prompt-textarea" rows="4"></textarea> <textarea id="rpg-prompt-spotify" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="spotify" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="spotify" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button>
</div>
<!-- Narrator Mode Prompt -->
<div class="rpg-prompt-editor-section">
<label for="rpg-prompt-narrator" style="display: block; margin-bottom: 8px; font-weight: 600;">
<i class="fa-solid fa-book-open"></i> Narrator Mode Prompt
</label>
<small style="display: block; margin-bottom: 8px; color: #888; font-size: 11px;">
Injected when "Narrator Mode" is enabled. Instructs AI to infer characters from context.
</small>
<textarea id="rpg-prompt-narrator" class="rpg-prompt-textarea" rows="3"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="narrator" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
@@ -767,7 +803,7 @@
</small> </small>
<textarea id="rpg-prompt-plot-random" class="rpg-prompt-textarea" rows="6"></textarea> <textarea id="rpg-prompt-plot-random" class="rpg-prompt-textarea" rows="6"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="plotRandom" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="plotRandom" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
@@ -781,7 +817,7 @@
</small> </small>
<textarea id="rpg-prompt-plot-natural" class="rpg-prompt-textarea" rows="4"></textarea> <textarea id="rpg-prompt-plot-natural" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="plotNatural" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="plotNatural" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
@@ -795,7 +831,7 @@
</small> </small>
<textarea id="rpg-prompt-avatar" class="rpg-prompt-textarea" rows="3"></textarea> <textarea id="rpg-prompt-avatar" class="rpg-prompt-textarea" rows="3"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="avatar" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="avatar" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
@@ -809,7 +845,7 @@
</small> </small>
<textarea id="rpg-prompt-tracker-instructions" class="rpg-prompt-textarea" rows="4"></textarea> <textarea id="rpg-prompt-tracker-instructions" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="trackerInstructions" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="trackerInstructions" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
@@ -823,7 +859,7 @@
</small> </small>
<textarea id="rpg-prompt-tracker-continuation" class="rpg-prompt-textarea" rows="4"></textarea> <textarea id="rpg-prompt-tracker-continuation" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="trackerContinuation" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="trackerContinuation" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
@@ -837,7 +873,7 @@
</small> </small>
<textarea id="rpg-prompt-combat-narrative" class="rpg-prompt-textarea" rows="6"></textarea> <textarea id="rpg-prompt-combat-narrative" class="rpg-prompt-textarea" rows="6"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="combatNarrative" style="margin-top: 8px;"> <button class="menu_button rpg-restore-prompt-btn" data-prompt="combatNarrative" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i> Restore Default <i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button> </button>
</div> </div>
</div> </div>
@@ -855,3 +891,81 @@
</footer> </footer>
</div> </div>
</div> </div>
<!-- Welcome Modal for v3.0 -->
<div id="rpg-welcome-modal" class="rpg-settings-popup" role="dialog" aria-modal="true"
aria-labelledby="rpg-welcome-title" style="display: none;">
<div class="rpg-settings-popup-content" style="max-width: 600px;">
<header class="rpg-settings-popup-header">
<h3 id="rpg-welcome-title">
<i class="fa-solid fa-stars"></i>
Welcome to RPG Companion v.3.0.0!
</h3>
<button id="rpg-welcome-close" class="rpg-popup-close" type="button"
aria-label="Close Welcome">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-settings-popup-body" style="max-height: 500px; overflow-y: auto; padding: 20px;">
<div style="background: rgba(255, 165, 0, 0.1); border-left: 3px solid orange; padding: 12px; margin-bottom: 20px;">
<strong style="color: orange;">⚠️ Important:</strong> Due to a change in the tracker format, we recommend that you use <strong>Clear Extension Cache</strong> in the Settings before you start using this version!
</div>
<h4 style="margin-top: 20px; margin-bottom: 10px;"><strong>What's new?</strong></h4>
<ul style="line-height: 1.6; margin-left: 20px;">
<li>Switched to the JSON format for the trackers.</li>
<li>You can now lock/unlock trackers that you don't want the model to change between generations.</li>
<li>Removed features that were half-baked or didn't work.</li>
<li>Organized Settings and Edit Trackers windows.</li>
<li>All features of the extension are now accessible from the main panel view.</li>
<li>Added Colored Dialogues option that makes the model color dialogue lines differently depending on the speaker.</li>
<li>Introduced Dynamic Weather Effects that add visual effects to your SillyTavern window depending on the current weather from the trackers.</li>
<li>All prompts used for the extension's features are now editable.</li>
<li>Made the user's level optional in the Edit Trackers.</li>
</ul>
<h4 style="margin-top: 20px; margin-bottom: 10px;"><strong>Bug Fixes:</strong></h4>
<ul style="line-height: 1.6; margin-left: 20px;">
<li>Fixed tracker logic in Together generation mode.</li>
<li>Fixed various UI bugs (too many to count).</li>
<li>Upgraded mobile view.</li>
<li>Spotify Music widget is more visible now, plus it works in the mobile view.</li>
<li>Auto-update after messages option is now available for External API generation mode.</li>
<li>Fixed the display of the thoughts window and its mobile display.</li>
<li>Fixed smaller bugs.</li>
</ul>
<h4 style="margin-top: 20px; margin-bottom: 10px;"><strong>Special thanks to all the other contributors for this project:</strong></h4>
<p style="margin-left: 20px; line-height: 1.6;">
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude (???), IDeathByte, Chungchandev, Joenunezb, and Amauragis!
</p>
<div style="margin-top: 20px; text-align: center;">
<p style="margin-bottom: 10px;">Join our Discord server to share your feedback, report bugs, or request new features!</p>
<a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button" style="display: inline-block; margin: 5px;">
<i class="fa-brands fa-discord"></i> Discord Server
</a>
</div>
<div style="margin-top: 20px; text-align: center;">
<p style="margin-bottom: 10px;">And don't forget to consider supporting me if you enjoy my work. Thank you!</p>
<a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button" style="display: inline-block; margin: 5px;">
<i class="fa-solid fa-heart"></i> Support on Ko-fi
</a>
</div>
<div style="margin-top: 30px; text-align: center; font-size: 18px; font-style: italic;">
<strong>Happy gooning!</strong>
</div>
<div style="margin-top: 10px; text-align: right; color: #888; font-style: italic;">
~ Marinara
</div>
</div>
<footer class="rpg-settings-popup-footer">
<button id="rpg-welcome-got-it" class="rpg-btn-primary" type="button" style="width: 100%;">
<i class="fa-solid fa-check"></i> Got it!
</button>
</footer>
</div>
</div>