Compare commits

...

57 Commits

Author SHA1 Message Date
Spicy_Marinara fd8afba7f2 v3.6.1: Dynamic combat actions and bug fixes
- Added dynamic action updates: AI can now modify available attacks/items based on combat state
- Items decrease when used, abilities change based on status effects
- Fixed event delegation for encounter buttons to work reliably on mobile
- Fixed multiple JSON parsing validation errors
- Added proper dialogue handling in combat summaries
- UI now re-renders action buttons when actions change
- Improved prompt instructions for item quantities and dynamic actions
2026-01-13 19:21:49 +01:00
Spicy Marinara c14250e467 Merge pull request #104 from IDeathByte/main
Ru language
2026-01-13 13:53:27 +01:00
Spicy_Marinara e8edc42164 v3.6.0 - Bug fixes and number display mode for stats
- Fixed custom status fields not being sent to prompts or parsed
- Fixed date format selection not working beyond default format
- Fixed widget text overflow issues with minimal scrollbars
- Added ability to display stats as numbers with custom max values instead of percentages
- Enabled desktop strip widgets by default
- Removed icon from Desktop Collapsed Strip Widgets heading
2026-01-13 13:52:18 +01:00
IDeathByte acf119d4b4 Add russian language 2026-01-13 14:35:06 +05:00
IDeathByte 6582095cc1 add russian 2026-01-13 13:51:16 +05:00
IDeathByte 8aaf258ba3 add russian 2026-01-13 13:50:33 +05:00
IDeathByte 7c1c140a2a add russian 2026-01-13 13:49:48 +05:00
Spicy Marinara ce668c4793 Merge pull request #101 from tomt610/feature/desktop-strip-widgets
feat: Add desktop collapsed strip widgets
2026-01-13 09:35:51 +01:00
tomt610 3d6db2b0e9 Fix strip refresh button visibility based on generation mode 2026-01-13 00:55:45 +00:00
tomt610 2151b2dae3 fix: Use absolute positioning for strip widget container to fill full panel height 2026-01-13 00:40:26 +00:00
tomt610 4644e0fd93 feat: Add desktop collapsed strip widgets
- Add new desktopStripWidgets settings in state.js with toggles for weather, clock, date, location, stats, and attributes
- Add strip widget container in template.html with animated clock face
- Add CSS styles for strip widgets with wider collapsed panel (5rem), vertical layout, and theme support
- Implement updateStripWidgets() in desktop.js to populate widgets from tracker data
- Wire up settings handlers in index.js for all strip widget toggles
- Call updateStripWidgets() on data updates in sillytavern.js integration
- Trigger widget update when panel is collapsed in layout.js

The strip widgets display compact stats/info in the collapsed panel strip on desktop, similar to mobile FAB widgets, eliminating the need to expand the panel to view basic data.
2026-01-13 00:08:00 +00:00
Spicy Marinara b18aaee0c0 Merge pull request #100 from tomt610/feature/improved-clear-weather-effects
Feature/improved clear weather effects
2026-01-13 00:23:34 +01:00
tomt610 0066b61746 Add sun/moon traveling across sky based on hour
- Sun position calculated from hour (5 AM - 8 PM arc trajectory)
- Moon position calculated from hour (8 PM - 5 AM arc trajectory)
- Celestial bodies move smoothly without resetting particles
- Reduced opacity for sun/moon in foreground mode for readability
- Fixed mobile viewport units (dvh/vw) for proper positioning
2026-01-12 23:21:19 +00:00
tomt610 6e9ff9812d Fix mobile view for weather effects
- Replace % units with dvh/vw for dynamic viewport sizing
- Fix stars, fireflies, dust motes, and light orbs positioning
- Fix moon and moon glow positioning to use dvh/vw
- Update snowfall and rainfall animations to use 100dvh
- Ensures proper distribution across full mobile viewport
2026-01-12 22:54:46 +00:00
tomt610 3797e21912 Improve clear weather effects with day/night cycle
- Replace blinking sunray lines with pleasant daytime effects:
  - Warm ambient glow overlay
  - Floating golden dust motes/pollen particles
  - Soft drifting light orbs
  - Subtle lens flare in corner

- Add automatic nighttime detection from Info Box time data:
  - Parses various time formats (12h, 24h, descriptive)
  - Night mode activates 8 PM - 5 AM

- Add nighttime clear weather effects:
  - Moon with realistic shading and glow (positioned left)
  - Twinkling stars with bright star cross-flares
  - Floating fireflies with gentle glow
  - Occasional shooting star animation

- Add mobile optimizations for all new effects
2026-01-12 22:45:48 +00:00
Spicy Marinara 7bac0d48f9 Merge pull request #99 from tomt610/fix/swipe-delete-state-restoration
Fix/swipe delete state restoration
2026-01-12 20:30:35 +01:00
Spicy Marinara 7081137fe3 Merge pull request #98 from tomt610/feature/infobox-edit-start-time
feat: add editable start time to infobox time widget
2026-01-12 20:29:56 +01:00
tomt610 3ceb64c3bd Fix RPG state restoration on message delete and swipe
- Add MESSAGE_DELETED event handler to restore state from last assistant message
- Fix swipe to use previous message's data for LLM context (prevents time/story advancing)
- Update UI to show rolled-back state immediately when triggering new swipe
- Handle edge cases: empty chat, first message swipe, no previous RPG data
2026-01-12 17:46:46 +00:00
tomt610 831c230b36 feat: add editable start time to infobox time widget 2026-01-12 13:12:38 +00:00
Spicy Marinara 3a6acb37be Merge pull request #96 from tomt610/fix/fab-spinning-together-mode
fix(fab): prevent spinning in together mode and update widgets
2026-01-12 02:38:48 +01:00
tomt610 ce8db67de4 fix(fab): prevent spinning in together mode and update widgets
- Remove FAB loading state trigger for together mode since no extra API request is made
- Add updateFabWidgets() call after rendering in together mode to update FAB display
- FAB spinning now correctly only occurs for separate/external modes
2026-01-11 23:26:01 +00:00
Spicy Marinara 0262218ad0 Merge pull request #95 from tomt610/feature/send-all-enabled-on-refresh
feat(history): Add 'Send All Enabled Stats on Refresh' option
2026-01-11 23:46:52 +01:00
tomt610 3fc2cfa8ab feat(history): Add 'Send All Enabled Stats on Refresh' option
Adds a new toggle in Edit Trackers -> History Persistence that allows
sending all enabled stats from the preset when using Refresh RPG Info,
instead of only the individually selected persistInHistory fields.

This helps the separate update AI understand the full context of what
has already been tracked and what changes it needs to account for,
improving coherence in stat updates without cluttering the main chat
history with excessive context data.
2026-01-11 22:01:26 +00:00
Spicy_Marinara c614f7b8dc v3.5.0: Weather effects improvements and dice roll fixes
- Refactor weather effects toggles to radio buttons in settings
  - Replace weatherEffectsForeground with weatherBackground/weatherForeground
  - Add Background/Foreground position options as radio toggles
  - Remove weather foreground toggle from main panel
- Fix dice roll to work independently of RPG attributes
  - Dice rolls now sent regardless of attribute settings
  - Adjust prompt wording based on whether attributes are enabled
- Improve History Persistence UI styling
  - Update input/select CSS to match tracker editor
  - Fix alignment issues
- Add theme-based radio button styling
  - Radio buttons now use theme colors instead of default blue
  - Support for all themes (default, sci-fi, fantasy, cyberpunk, custom)
- Update weather effects z-index logic for both modes
- Bump version to v3.5.0
2026-01-11 20:05:35 +01:00
Spicy_Marinara 46e6de0eba Update apiClient.js 2026-01-11 19:35:26 +01:00
Spicy Marinara e2a48a4075 Merge pull request #94 from tomt610/feature/weather-foreground-option
feat: Add weather foreground option (experimental)
2026-01-11 19:33:14 +01:00
Spicy Marinara 8d41010509 Merge pull request #93 from tomt610/fix/default-prompt
fix(presets): defer association changes until Save & Apply
2026-01-11 19:33:01 +01:00
Spicy Marinara 95d5616141 Merge pull request #92 from tomt610/fix/historical-context-injection
fix: Historical context injection for both text and chat completion p…
2026-01-11 19:32:33 +01:00
Spicy_Marinara 5918e38ade v3.2.1 2026-01-11 19:19:52 +01:00
tomt610 bb3028adbb feat: Add weather foreground option (experimental)
- Add weatherEffectsForeground setting to render weather effects in front of chat
- Add UI toggle in main panel (visible when Dynamic Weather toggle is visible)
- Apply z-index 9998 when foreground option is enabled
- Fix weather container sizing with viewport units (100vh/100dvh) for better mobile support
2026-01-11 15:38:47 +00:00
tomt610 bc4f50a82f fix(presets): defer association changes until Save & Apply
- Add isAssociatedWithCurrentPreset() helper to check if entity is associated with the CURRENT preset (not just any preset)
- Fix checkbox to correctly reflect association with currently selected preset
- Introduce tempAssociation state to track pending association changes
- Only save association changes when clicking Save & Apply, not on preset switch
- Discard pending association changes when clicking X/Cancel
- Auto-update association when switching presets if checkbox was checked
- Improve toast messages to clarify when changes will be applied

Fixes issue where checkbox showed incorrect state and association was saved immediately without waiting for Save & Apply.
2026-01-11 14:58:17 +00:00
tomt610 126cfedaa4 fix: Historical context injection for both text and chat completion prompts
- Fix swipe data retrieval to check both message.extra and swipe_info sources
- Fix user_message_end position to inject into preceding (not next) user message
- Add ordered content-matching for text completion prompt injection
- Add ordered content-matching for chat completion prompt injection
- Remove unnecessary HTML entity normalization
- Clean up unused imports and variables
2026-01-11 13:45:42 +00:00
Spicy_Marinara f3deead868 v3.4.1: Fix Present Characters not included in <previous> section for separate generation mode
- Fixed bug where Present Characters data wasn't appearing in the <previous> section when generating new trackers in separate mode
- Root cause: committedTrackerData.characterThoughts is stored as a JS array, not a JSON string
- Solution: Check data type before parsing - handle both object/array and string formats
- Present Characters data now correctly included in unified previous tracker JSON regardless of showCharacterThoughts setting
2026-01-11 00:17:49 +01:00
Spicy_Marinara d5d649f122 Update promptBuilder.js 2026-01-10 21:21:25 +01:00
Spicy Marinara 0cd764c39b Merge pull request #90 from tomt610/feature/history-persistence
Feature/history persistence
2026-01-10 20:35:03 +01:00
tomt610 b9a15722d6 Fix history injection for prewarm extensions
- Use persistent event listeners instead of once() to inject into ALL generations
- Don't clear context map on GENERATION_ENDED so prewarm gets the same context
- Remove unused onGenerationEndedCleanup function
2026-01-10 19:33:26 +00:00
Spicy_Marinara 995f3a7a98 Add Deception System and CYOA features with toggles, custom prompts, and proper injection ordering 2026-01-10 20:24:41 +01:00
tomt610 db97f012b0 Refactor history injection to modify prompts instead of chat messages
This prevents any risk of injected context being accidentally saved to the chat.
Instead of modifying chat[].mes directly, we now:
1. Build a context map during GENERATION_STARTED
2. Inject into the prompt string (GENERATE_AFTER_COMBINE_PROMPTS) for text completion
3. Inject into the message array (CHAT_COMPLETION_PROMPT_READY) for chat completion

The original chat messages are never modified.
2026-01-10 19:10:33 +00:00
Spicy_Marinara 681b8ba2bc Merge remote-tracking branch 'tomt610/feature/fab-widgets' into test-pr90-pr91-combined 2026-01-10 16:47:15 +01:00
Spicy_Marinara 2d961936c2 Merge remote-tracking branch 'tomt610/feature/history-persistence' into test-pr90-pr91-combined 2026-01-10 16:47:12 +01:00
Spicy_Marinara b534bd4c71 Update README.md 2026-01-10 16:41:27 +01:00
tomt610 73cbb27713 feat(mobile): Add FAB widgets with info display around toggle button
- Add 8-position widget system around mobile FAB button (N, NE, E, SE, S, SW, W, NW)
- Display weather icon, weather description, time, date, location around FAB
- Show stats and RPG attributes in larger West/Northwest positions
- Add animated clock face matching main panel design
- Implement expandable text on hover/tap for truncated content
- Add FAB spinner animation during API requests
- Respect tracker preset settings for filtering displayed stats/attributes
- Sync FAB data with lastGeneratedData for real-time updates
- Hide FAB widgets on desktop viewport (>1000px) and when panel is open
- Add settings UI for enabling/disabling individual widget types
- Update FAB widgets on manual edits in tracker editor and stats panels
2026-01-10 13:25:40 +00:00
tomt610 db2bed16a7 Clean up debug logging in history injection 2026-01-09 21:14:21 +00:00
tomt610 ecb5d74d6e Enhance preset saving and loading to include historyPersistence 2026-01-09 21:05:11 +00:00
tomt610 fea59efe4e Refactor historical context handling and remove unused initialization function 2026-01-09 20:51:28 +00:00
tomt610 b43cca5b6f Refactor message injection options in promptBuilder and trackerEditor 2026-01-09 20:24:07 +00:00
tomt610 94f562f1bb Refactor message restoration logic to use a one-time event listener 2026-01-09 20:20:04 +00:00
tomt610 3d5fc5fee1 Refactor historical context injection logic to support dynamic message indexing based on injection position 2026-01-09 20:10:21 +00:00
tomt610 98ef751a9f Implement historical context injection for chat messages and enhance settings for persistence 2026-01-09 19:39:05 +00:00
Spicy_Marinara f5641ec1f0 Merge branch 'pr-88' 2026-01-09 12:11:51 +01:00
Spicy_Marinara 0320c3fdd5 Fix duplicate keys in en.json and add missing periods to user-facing messages 2026-01-09 12:08:53 +01:00
Spicy_Marinara 3d0ebe4694 Update en.json 2026-01-09 12:00:55 +01:00
Spicy_Marinara 510723cac4 v3.3.3
- Strengthened default prompts to not include user's persona in the characters' section.
- Updated some descriptions for buttons and custom fields.
2026-01-09 11:54:43 +01:00
tomt610 f6733f87a2 feat: Add preset management system for tracker configurations
- Add preset selector dropdown in tracker editor modal
- Support creating, loading, and deleting presets
- Add per-character/group preset associations with auto-switch
- Add default preset functionality with star button
- Update import to offer 'Apply to Current' or 'Create New Preset' options
- Add preset management UI styles and import dialog styles
2026-01-09 10:38:57 +00:00
Spicy_Marinara ddc02d9bbc Release v3.3.2: Fix auto-update on chat switch & restore character removal 2026-01-09 10:04:29 +01:00
Spicy Marinara 659b5bb82b Merge pull request #87 from tomt610/fix/quest-removal-sync
Fix: Sync quest changes to committedTrackerData
2026-01-09 09:29:28 +01:00
tomt610 5f72e6f549 Fix: Sync quest changes to committedTrackerData
When manually adding/editing/removing quests via UI, the changes were
only saved to extensionSettings but not to committedTrackerData.userStats.
This caused the AI to see stale quest data on the next external server
update, resulting in removed quests reappearing.

- Add syncQuestsToCommittedData() function to update JSON quest data
- Call sync and saveChatData() on all quest modification actions
- Imports committedTrackerData, lastGeneratedData, saveChatData
2026-01-09 00:23:23 +00:00
33 changed files with 6708 additions and 417 deletions
+5 -5
View File
@@ -7,11 +7,11 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
## 🆕 What's New
### v3.3.1
- Thought bubble can now be collapsed into an icon.
- Fixed a bug for Past Events being parsed incorrectly.
- Added event emission on when the tracker generation is complete.
### v3.6.1
- Fixed the bugs in the encounter system where you couldn't use the buttons after performing any custom action.
- Improved combat actions and made them dynamic, depending on the current situation.
- Added Russian as a supported language.
**Special thanks to all the other contributors for this project:**
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
+247 -4
View File
@@ -64,6 +64,7 @@ import { renderInfoBox, updateInfoBoxField } from './src/systems/rendering/infoB
import {
renderThoughts,
updateCharacterField,
removeCharacter,
updateChatThoughts,
createThoughtPanel
} from './src/systems/rendering/thoughts.js';
@@ -125,11 +126,13 @@ import {
removeMobileTabs,
setupMobileKeyboardHandling,
setupContentEditableScrolling,
updateMobileTabLabels
updateMobileTabLabels,
updateFabWidgets
} from './src/systems/ui/mobile.js';
import {
setupDesktopTabs,
removeDesktopTabs
removeDesktopTabs,
updateStripWidgets
} from './src/systems/ui/desktop.js';
// Feature modules
@@ -148,9 +151,11 @@ import {
onMessageReceived,
onCharacterChanged,
onMessageSwiped,
onMessageDeleted,
updatePersonaAvatar,
clearExtensionPrompts,
onGenerationEnded
onGenerationEnded,
initHistoryInjection
} from './src/systems/integration/sillytavern.js';
// Old state variable declarations removed - now imported from core modules
@@ -375,6 +380,16 @@ async function initUI() {
saveSettings();
});
$('#rpg-toggle-deception').on('change', function() {
extensionSettings.enableDeceptionSystem = $(this).prop('checked');
saveSettings();
});
$('#rpg-toggle-cyoa').on('change', function() {
extensionSettings.enableCYOA = $(this).prop('checked');
saveSettings();
});
$('#rpg-toggle-spotify-music').on('change', function() {
extensionSettings.enableSpotifyMusic = $(this).prop('checked');
saveSettings();
@@ -552,6 +567,18 @@ async function initUI() {
updateFeatureTogglesVisibility();
});
$('#rpg-toggle-show-deception-toggle').on('change', function() {
extensionSettings.showDeceptionToggle = $(this).prop('checked');
saveSettings();
updateFeatureTogglesVisibility();
});
$('#rpg-toggle-show-cyoa-toggle').on('change', function() {
extensionSettings.showCYOAToggle = $(this).prop('checked');
saveSettings();
updateFeatureTogglesVisibility();
});
$('#rpg-toggle-show-spotify-toggle').on('change', function() {
extensionSettings.showSpotifyToggle = $(this).prop('checked');
saveSettings();
@@ -568,6 +595,34 @@ async function initUI() {
}
saveSettings();
updateFeatureTogglesVisibility();
updateWeatherSubOptionsVisibility();
});
// Weather sub-options (background and foreground) - radio buttons
$('#rpg-toggle-weather-background').on('change', function() {
if ($(this).prop('checked')) {
extensionSettings.weatherBackground = true;
extensionSettings.weatherForeground = false;
saveSettings();
// Re-apply weather effect
if (extensionSettings.enableDynamicWeather) {
toggleDynamicWeather(false);
toggleDynamicWeather(true);
}
}
});
$('#rpg-toggle-weather-foreground').on('change', function() {
if ($(this).prop('checked')) {
extensionSettings.weatherBackground = false;
extensionSettings.weatherForeground = true;
saveSettings();
// Re-apply weather effect
if (extensionSettings.enableDynamicWeather) {
toggleDynamicWeather(false);
toggleDynamicWeather(true);
}
}
});
$('#rpg-toggle-show-narrator-mode').on('change', function() {
@@ -607,6 +662,128 @@ async function initUI() {
updateDiceDisplay();
});
// Mobile FAB Widget toggles - simplified, no position saving (auto-positioned)
$('#rpg-toggle-fab-widgets-enabled').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
extensionSettings.mobileFabWidgets.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
$('#rpg-fab-widget-options').toggle(extensionSettings.mobileFabWidgets.enabled);
});
$('#rpg-toggle-fab-weather-icon').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
if (!extensionSettings.mobileFabWidgets.weatherIcon) extensionSettings.mobileFabWidgets.weatherIcon = {};
extensionSettings.mobileFabWidgets.weatherIcon.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
});
$('#rpg-toggle-fab-weather-desc').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
if (!extensionSettings.mobileFabWidgets.weatherDesc) extensionSettings.mobileFabWidgets.weatherDesc = {};
extensionSettings.mobileFabWidgets.weatherDesc.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
});
$('#rpg-toggle-fab-clock').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
if (!extensionSettings.mobileFabWidgets.clock) extensionSettings.mobileFabWidgets.clock = {};
extensionSettings.mobileFabWidgets.clock.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
});
$('#rpg-toggle-fab-date').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
if (!extensionSettings.mobileFabWidgets.date) extensionSettings.mobileFabWidgets.date = {};
extensionSettings.mobileFabWidgets.date.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
});
$('#rpg-toggle-fab-location').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
if (!extensionSettings.mobileFabWidgets.location) extensionSettings.mobileFabWidgets.location = {};
extensionSettings.mobileFabWidgets.location.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
});
$('#rpg-toggle-fab-stats').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
if (!extensionSettings.mobileFabWidgets.stats) extensionSettings.mobileFabWidgets.stats = {};
extensionSettings.mobileFabWidgets.stats.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
});
$('#rpg-toggle-fab-attributes').on('change', function() {
if (!extensionSettings.mobileFabWidgets) extensionSettings.mobileFabWidgets = {};
if (!extensionSettings.mobileFabWidgets.attributes) extensionSettings.mobileFabWidgets.attributes = {};
extensionSettings.mobileFabWidgets.attributes.enabled = $(this).prop('checked');
saveSettings();
updateFabWidgets();
});
// Desktop Strip Widget toggles
$('#rpg-toggle-strip-widgets-enabled').on('change', function() {
if (!extensionSettings.desktopStripWidgets) extensionSettings.desktopStripWidgets = {};
extensionSettings.desktopStripWidgets.enabled = $(this).prop('checked');
saveSettings();
updateStripWidgets();
$('#rpg-strip-widget-options').toggle(extensionSettings.desktopStripWidgets.enabled);
});
$('#rpg-toggle-strip-weather-icon').on('change', function() {
if (!extensionSettings.desktopStripWidgets) extensionSettings.desktopStripWidgets = {};
if (!extensionSettings.desktopStripWidgets.weatherIcon) extensionSettings.desktopStripWidgets.weatherIcon = {};
extensionSettings.desktopStripWidgets.weatherIcon.enabled = $(this).prop('checked');
saveSettings();
updateStripWidgets();
});
$('#rpg-toggle-strip-clock').on('change', function() {
if (!extensionSettings.desktopStripWidgets) extensionSettings.desktopStripWidgets = {};
if (!extensionSettings.desktopStripWidgets.clock) extensionSettings.desktopStripWidgets.clock = {};
extensionSettings.desktopStripWidgets.clock.enabled = $(this).prop('checked');
saveSettings();
updateStripWidgets();
});
$('#rpg-toggle-strip-date').on('change', function() {
if (!extensionSettings.desktopStripWidgets) extensionSettings.desktopStripWidgets = {};
if (!extensionSettings.desktopStripWidgets.date) extensionSettings.desktopStripWidgets.date = {};
extensionSettings.desktopStripWidgets.date.enabled = $(this).prop('checked');
saveSettings();
updateStripWidgets();
});
$('#rpg-toggle-strip-location').on('change', function() {
if (!extensionSettings.desktopStripWidgets) extensionSettings.desktopStripWidgets = {};
if (!extensionSettings.desktopStripWidgets.location) extensionSettings.desktopStripWidgets.location = {};
extensionSettings.desktopStripWidgets.location.enabled = $(this).prop('checked');
saveSettings();
updateStripWidgets();
});
$('#rpg-toggle-strip-stats').on('change', function() {
if (!extensionSettings.desktopStripWidgets) extensionSettings.desktopStripWidgets = {};
if (!extensionSettings.desktopStripWidgets.stats) extensionSettings.desktopStripWidgets.stats = {};
extensionSettings.desktopStripWidgets.stats.enabled = $(this).prop('checked');
saveSettings();
updateStripWidgets();
});
$('#rpg-toggle-strip-attributes').on('change', function() {
if (!extensionSettings.desktopStripWidgets) extensionSettings.desktopStripWidgets = {};
if (!extensionSettings.desktopStripWidgets.attributes) extensionSettings.desktopStripWidgets.attributes = {};
extensionSettings.desktopStripWidgets.attributes.enabled = $(this).prop('checked');
saveSettings();
updateStripWidgets();
});
$('#rpg-manual-update').on('click', async function() {
if (!extensionSettings.enabled) {
// console.log('[RPG Companion] Extension is disabled. Please enable it in the Extensions tab.');
@@ -615,6 +792,14 @@ async function initUI() {
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
});
// Strip widget refresh button - same functionality as main refresh button
$('#rpg-strip-refresh').on('click', async function() {
if (!extensionSettings.enabled) {
return;
}
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
});
$('#rpg-stat-bar-color-low').on('change', function() {
extensionSettings.statBarColorLow = String($(this).val());
saveSettings();
@@ -784,6 +969,8 @@ async function initUI() {
$('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat);
$('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt);
$('#rpg-toggle-dialogue-coloring').prop('checked', extensionSettings.enableDialogueColoring);
$('#rpg-toggle-deception').prop('checked', extensionSettings.enableDeceptionSystem ?? false);
$('#rpg-toggle-cyoa').prop('checked', extensionSettings.enableCYOA ?? false);
$('#rpg-toggle-spotify-music').prop('checked', extensionSettings.enableSpotifyMusic);
$('#rpg-toggle-dynamic-weather').prop('checked', extensionSettings.enableDynamicWeather);
@@ -792,8 +979,12 @@ async function initUI() {
// Feature toggle visibility settings
$('#rpg-toggle-show-html-toggle').prop('checked', extensionSettings.showHtmlToggle ?? true);
$('#rpg-toggle-show-dialogue-coloring-toggle').prop('checked', extensionSettings.showDialogueColoringToggle ?? true);
$('#rpg-toggle-show-deception-toggle').prop('checked', extensionSettings.showDeceptionToggle ?? true);
$('#rpg-toggle-show-cyoa-toggle').prop('checked', extensionSettings.showCYOAToggle ?? true);
$('#rpg-toggle-show-spotify-toggle').prop('checked', extensionSettings.showSpotifyToggle ?? true);
$('#rpg-toggle-show-dynamic-weather-toggle').prop('checked', extensionSettings.showDynamicWeatherToggle ?? true);
$('#rpg-toggle-weather-background').prop('checked', extensionSettings.weatherBackground ?? true);
$('#rpg-toggle-weather-foreground').prop('checked', extensionSettings.weatherForeground ?? false);
$('#rpg-toggle-show-narrator-mode').prop('checked', extensionSettings.showNarratorMode ?? true);
$('#rpg-toggle-show-auto-avatars').prop('checked', extensionSettings.showAutoAvatars ?? true);
@@ -824,6 +1015,32 @@ async function initUI() {
$('#rpg-toggle-auto-avatars-panel').prop('checked', extensionSettings.autoGenerateAvatars || false);
$('#rpg-toggle-dice-display').prop('checked', extensionSettings.showDiceDisplay);
// Initialize Mobile FAB Widget checkboxes
const fabWidgets = extensionSettings.mobileFabWidgets || {};
$('#rpg-toggle-fab-widgets-enabled').prop('checked', fabWidgets.enabled || false);
$('#rpg-toggle-fab-weather-icon').prop('checked', fabWidgets.weatherIcon?.enabled || false);
$('#rpg-toggle-fab-weather-desc').prop('checked', fabWidgets.weatherDesc?.enabled || false);
$('#rpg-toggle-fab-clock').prop('checked', fabWidgets.clock?.enabled || false);
$('#rpg-toggle-fab-date').prop('checked', fabWidgets.date?.enabled || false);
$('#rpg-toggle-fab-location').prop('checked', fabWidgets.location?.enabled || false);
$('#rpg-toggle-fab-stats').prop('checked', fabWidgets.stats?.enabled || false);
$('#rpg-toggle-fab-attributes').prop('checked', fabWidgets.attributes?.enabled || false);
// Toggle visibility of widget options based on master toggle
$('#rpg-fab-widget-options').toggle(fabWidgets.enabled || false);
// Initialize Desktop Strip Widget checkboxes
const stripWidgets = extensionSettings.desktopStripWidgets || {};
$('#rpg-toggle-strip-widgets-enabled').prop('checked', stripWidgets.enabled || false);
$('#rpg-toggle-strip-weather-icon').prop('checked', stripWidgets.weatherIcon?.enabled ?? true);
$('#rpg-toggle-strip-clock').prop('checked', stripWidgets.clock?.enabled ?? true);
$('#rpg-toggle-strip-date').prop('checked', stripWidgets.date?.enabled ?? true);
$('#rpg-toggle-strip-location').prop('checked', stripWidgets.location?.enabled ?? true);
$('#rpg-toggle-strip-stats').prop('checked', stripWidgets.stats?.enabled ?? true);
$('#rpg-toggle-strip-attributes').prop('checked', stripWidgets.attributes?.enabled ?? true);
// Toggle visibility of strip widget options based on master toggle
$('#rpg-strip-widget-options').toggle(stripWidgets.enabled || false);
$('#rpg-stat-bar-color-low').val(extensionSettings.statBarColorLow);
$('#rpg-stat-bar-color-high').val(extensionSettings.statBarColorHigh);
$('#rpg-theme-select').val(extensionSettings.theme);
@@ -847,7 +1064,6 @@ async function initUI() {
$('#rpg-generation-mode').val(extensionSettings.generationMode);
$('#rpg-skip-guided-mode').val(extensionSettings.skipInjectionsForGuided);
$('#rpg-save-tracker-history').prop('checked', extensionSettings.saveTrackerHistory);
updatePanelVisibility();
updateSectionVisibility();
@@ -968,6 +1184,9 @@ jQuery(async () => {
// Load chat-specific data for current chat
try {
loadChatData();
// Initialize FAB widgets and strip widgets with any loaded data
updateFabWidgets();
updateStripWidgets();
} catch (error) {
console.error('[RPG Companion] Chat data load failed, using defaults:', error);
}
@@ -1017,6 +1236,15 @@ jQuery(async () => {
// Non-critical - continue anyway
}
// Initialize history injection event listeners
// This must be done before event registration so listeners are ready
try {
initHistoryInjection();
} catch (error) {
console.error('[RPG Companion] History injection init failed:', error);
// Non-critical - continue without it
}
// Register all event listeners
try {
registerAllEvents({
@@ -1027,6 +1255,7 @@ jQuery(async () => {
[event_types.GENERATION_ENDED]: onGenerationEnded,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts],
[event_types.MESSAGE_SWIPED]: onMessageSwiped,
[event_types.MESSAGE_DELETED]: onMessageDeleted,
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar
});
@@ -1067,3 +1296,17 @@ jQuery(async () => {
);
}
});
/**
* Updates the visibility of weather sub-options in settings based on dynamic weather toggle
*/
function updateWeatherSubOptionsVisibility() {
const $weatherSubOptions = $('#rpg-weather-suboptions');
const isDynamicWeatherEnabled = extensionSettings.showDynamicWeatherToggle ?? true;
if (isDynamicWeatherEnabled) {
$weatherSubOptions.show();
} else {
$weatherSubOptions.hide();
}
}
+1 -1
View File
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Marinara",
"version": "3.3.1",
"version": "3.6.1",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+1 -1
View File
@@ -48,7 +48,7 @@
</div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.3.1
v3.6.1
</div>
</div>
</div>
+492 -3
View File
@@ -100,6 +100,24 @@ export function loadSettings() {
settingsChanged = true;
}
// Migration to version 4: Enable FAB widgets by default
if (currentVersion < 4) {
// console.log('[RPG Companion] Migrating settings to version 4 (enabling FAB widgets)');
if (!extensionSettings.mobileFabWidgets) {
extensionSettings.mobileFabWidgets = {};
}
extensionSettings.mobileFabWidgets.enabled = true;
extensionSettings.mobileFabWidgets.weatherIcon = { enabled: true };
extensionSettings.mobileFabWidgets.weatherDesc = { enabled: true };
extensionSettings.mobileFabWidgets.clock = { enabled: true };
extensionSettings.mobileFabWidgets.date = { enabled: true };
extensionSettings.mobileFabWidgets.location = { enabled: true };
extensionSettings.mobileFabWidgets.stats = { enabled: true };
extensionSettings.mobileFabWidgets.attributes = { enabled: true };
extensionSettings.settingsVersion = 4;
settingsChanged = true;
}
// Save migrated settings
if (settingsChanged) {
saveSettings();
@@ -126,6 +144,15 @@ export function loadSettings() {
migrateToTrackerConfig();
saveSettings(); // Persist migration
}
// Migrate to preset manager system if presets don't exist
migrateToPresetManager();
// Initialize custom status fields
initializeCustomStatusFields();
// Ensure all stats have maxValue (for number display mode)
ensureStatsHaveMaxValue();
} catch (error) {
console.error('[RPG Companion] Error loading settings:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
@@ -455,7 +482,7 @@ function migrateToTrackerConfig() {
{ id: 'physicalState', label: 'Physical State', enabled: true, placeholder: 'Visible Physical State (up to three traits)' },
{ id: 'demeanor', label: 'Demeanor Cue', enabled: true, placeholder: 'Observable Demeanor Cue (one trait)' },
{ id: 'relationship', label: 'Relationship', enabled: true, type: 'relationship', placeholder: 'Enemy/Neutral/Friend/Lover' },
{ id: 'internalMonologue', label: 'Internal Monologue', enabled: true, placeholder: 'Internal Monologue (in first person POV, up to three sentences long)' }
{ id: 'internalMonologue', label: 'Internal Monologue', enabled: true, placeholder: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)' }
],
characterStats: {
enabled: false,
@@ -555,7 +582,7 @@ function migrateToTrackerConfig() {
const thoughts = {
enabled: thoughtsField ? (thoughtsField.enabled !== false) : true,
name: 'Thoughts',
description: thoughtsField?.placeholder || 'Internal monologue (in first person POV, up to three sentences long)'
description: thoughtsField?.placeholder || 'Internal Monologue (in first person from character\'s POV, up to three sentences long)'
};
// Update to new structure
@@ -601,8 +628,470 @@ function migrateToTrackerConfig() {
pc.thoughts = {
enabled: true,
name: 'Thoughts',
description: 'Internal monologue (in first person POV, up to three sentences long)'
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)'
};
}
}
}
// ============================================================================
// Preset Management Functions
// ============================================================================
/**
* Gets the entity key for the current character or group
* @returns {string|null} Entity key in format "char_{id}" or "group_{id}", or null if no character selected
*/
export function getCurrentEntityKey() {
const context = getContext();
if (context.groupId) {
return `group_${context.groupId}`;
} else if (context.characterId !== undefined && context.characterId !== null) {
return `char_${context.characterId}`;
}
return null;
}
/**
* Gets the display name for the current character or group
* @returns {string} Display name for the current entity
*/
export function getCurrentEntityName() {
const context = getContext();
if (context.groupId) {
const group = context.groups?.find(g => g.id === context.groupId);
return group?.name || 'Group Chat';
} else if (context.characterId !== undefined && context.characterId !== null) {
return context.name2 || 'Character';
}
return 'No Character';
}
/**
* Migrates existing trackerConfig to the preset system if presetManager doesn't exist
* Creates a "Default" preset from the current trackerConfig
*/
export function migrateToPresetManager() {
if (!extensionSettings.presetManager || Object.keys(extensionSettings.presetManager.presets || {}).length === 0) {
// console.log('[RPG Companion] Migrating to preset manager system');
// Initialize presetManager if it doesn't exist
if (!extensionSettings.presetManager) {
extensionSettings.presetManager = {
presets: {},
characterAssociations: {},
activePresetId: null,
defaultPresetId: null
};
}
// Create default preset from existing trackerConfig
const defaultPresetId = 'preset_default';
extensionSettings.presetManager.presets[defaultPresetId] = {
id: defaultPresetId,
name: 'Default',
trackerConfig: JSON.parse(JSON.stringify(extensionSettings.trackerConfig))
};
extensionSettings.presetManager.activePresetId = defaultPresetId;
extensionSettings.presetManager.defaultPresetId = defaultPresetId;
// console.log('[RPG Companion] Created Default preset from existing trackerConfig');
saveSettings();
}
}
/**
* Initializes custom status fields in userStats based on trackerConfig
* Ensures all defined custom status fields have a value in the userStats object
*/
function initializeCustomStatusFields() {
const customFields = extensionSettings.trackerConfig?.userStats?.statusSection?.customFields || [];
// Initialize each custom field if it doesn't exist
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
if (extensionSettings.userStats[fieldKey] === undefined) {
extensionSettings.userStats[fieldKey] = 'None';
// console.log(`[RPG Companion] Initialized custom status field: ${fieldKey}`);
}
}
}
/**
* Ensures all custom stats have a maxValue property
* This migration supports the number display mode feature
*/
function ensureStatsHaveMaxValue() {
const customStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
for (const stat of customStats) {
if (stat && stat.maxValue === undefined) {
stat.maxValue = 100; // Default to 100 for backward compatibility
// console.log(`[RPG Companion] Added maxValue to stat: ${stat.id || stat.name}`);
}
}
// Ensure statsDisplayMode is set (default to percentage)
if (extensionSettings.trackerConfig?.userStats &&
extensionSettings.trackerConfig.userStats.statsDisplayMode === undefined) {
extensionSettings.trackerConfig.userStats.statsDisplayMode = 'percentage';
// console.log('[RPG Companion] Initialized statsDisplayMode to percentage');
}
}
/**
* Gets all available presets
* @returns {Object} Map of preset ID to preset data
*/
export function getPresets() {
return extensionSettings.presetManager?.presets || {};
}
/**
* Gets a specific preset by ID
* @param {string} presetId - The preset ID
* @returns {Object|null} The preset object or null if not found
*/
export function getPreset(presetId) {
return extensionSettings.presetManager?.presets?.[presetId] || null;
}
/**
* Gets the currently active preset ID
* @returns {string|null} The active preset ID or null
*/
export function getActivePresetId() {
return extensionSettings.presetManager?.activePresetId || null;
}
/**
* Gets the default preset ID
* @returns {string|null} The default preset ID or null
*/
export function getDefaultPresetId() {
return extensionSettings.presetManager?.defaultPresetId || null;
}
/**
* Sets a preset as the default
* @param {string} presetId - The preset ID to set as default
*/
export function setDefaultPreset(presetId) {
if (extensionSettings.presetManager.presets[presetId]) {
extensionSettings.presetManager.defaultPresetId = presetId;
saveSettings();
// console.log(`[RPG Companion] Set preset ${presetId} as default`);
}
}
/**
* Checks if the given preset is the default
* @param {string} presetId - The preset ID to check
* @returns {boolean} True if it's the default preset
*/
export function isDefaultPreset(presetId) {
return extensionSettings.presetManager?.defaultPresetId === presetId;
}
/**
* Creates a new preset from the current trackerConfig
* @param {string} name - Name for the new preset
* @returns {string} The ID of the newly created preset
*/
export function createPreset(name) {
const presetId = `preset_${Date.now()}`;
extensionSettings.presetManager.presets[presetId] = {
id: presetId,
name: name,
trackerConfig: JSON.parse(JSON.stringify(extensionSettings.trackerConfig)),
historyPersistence: extensionSettings.historyPersistence
? JSON.parse(JSON.stringify(extensionSettings.historyPersistence))
: null
};
// Also set it as the active preset so edits go to the new preset
extensionSettings.presetManager.activePresetId = presetId;
saveSettings();
// console.log(`[RPG Companion] Created preset "${name}" with ID ${presetId}`);
return presetId;
}
/**
* Saves the current trackerConfig and historyPersistence to the specified preset
* @param {string} presetId - The preset ID to save to
*/
export function saveToPreset(presetId) {
const preset = extensionSettings.presetManager.presets[presetId];
if (preset) {
preset.trackerConfig = JSON.parse(JSON.stringify(extensionSettings.trackerConfig));
preset.historyPersistence = extensionSettings.historyPersistence
? JSON.parse(JSON.stringify(extensionSettings.historyPersistence))
: null;
saveSettings();
// console.log(`[RPG Companion] Saved current config to preset "${preset.name}"`);
}
}
/**
* Loads a preset's trackerConfig and historyPersistence as the active configuration
* @param {string} presetId - The preset ID to load
* @returns {boolean} True if loaded successfully, false otherwise
*/
export function loadPreset(presetId) {
const preset = extensionSettings.presetManager.presets[presetId];
if (preset && preset.trackerConfig) {
extensionSettings.trackerConfig = JSON.parse(JSON.stringify(preset.trackerConfig));
// Load historyPersistence if present, otherwise use defaults
if (preset.historyPersistence) {
extensionSettings.historyPersistence = JSON.parse(JSON.stringify(preset.historyPersistence));
} else {
// Default values for presets that don't have historyPersistence yet
extensionSettings.historyPersistence = {
enabled: false,
messageCount: 5,
injectionPosition: 'assistant_message_end',
contextPreamble: ''
};
}
extensionSettings.presetManager.activePresetId = presetId;
saveSettings();
// console.log(`[RPG Companion] Loaded preset "${preset.name}"`);
return true;
}
return false;
}
/**
* Renames a preset
* @param {string} presetId - The preset ID to rename
* @param {string} newName - The new name for the preset
*/
export function renamePreset(presetId, newName) {
const preset = extensionSettings.presetManager.presets[presetId];
if (preset) {
preset.name = newName;
saveSettings();
// console.log(`[RPG Companion] Renamed preset to "${newName}"`);
}
}
/**
* Deletes a preset
* @param {string} presetId - The preset ID to delete
* @returns {boolean} True if deleted, false if it's the last preset (can't delete)
*/
export function deletePreset(presetId) {
const presets = extensionSettings.presetManager.presets;
const presetIds = Object.keys(presets);
// Don't delete if it's the last preset
if (presetIds.length <= 1) {
// console.warn('[RPG Companion] Cannot delete the last preset');
return false;
}
// Remove any character associations using this preset
const associations = extensionSettings.presetManager.characterAssociations;
for (const entityKey of Object.keys(associations)) {
if (associations[entityKey] === presetId) {
delete associations[entityKey];
}
}
// Delete the preset
delete presets[presetId];
// If the deleted preset was active, switch to the first available preset
if (extensionSettings.presetManager.activePresetId === presetId) {
const remainingIds = Object.keys(presets);
if (remainingIds.length > 0) {
loadPreset(remainingIds[0]);
}
}
saveSettings();
// console.log(`[RPG Companion] Deleted preset ${presetId}`);
return true;
}
/**
* Associates the current preset with the current character/group
*/
export function associatePresetWithCurrentEntity() {
const entityKey = getCurrentEntityKey();
const activePresetId = extensionSettings.presetManager.activePresetId;
if (entityKey && activePresetId) {
extensionSettings.presetManager.characterAssociations[entityKey] = activePresetId;
saveSettings();
// console.log(`[RPG Companion] Associated preset ${activePresetId} with ${entityKey}`);
}
}
/**
* Removes the preset association for the current character/group
*/
export function removePresetAssociationForCurrentEntity() {
const entityKey = getCurrentEntityKey();
if (entityKey && extensionSettings.presetManager.characterAssociations[entityKey]) {
delete extensionSettings.presetManager.characterAssociations[entityKey];
saveSettings();
// console.log(`[RPG Companion] Removed preset association for ${entityKey}`);
}
}
/**
* Gets the preset ID associated with the current character/group
* @returns {string|null} The associated preset ID or null
*/
export function getPresetForCurrentEntity() {
const entityKey = getCurrentEntityKey();
if (entityKey) {
return extensionSettings.presetManager.characterAssociations[entityKey] || null;
}
return null;
}
/**
* Checks if the current character/group has a preset association
* @returns {boolean} True if there's an association
*/
export function hasPresetAssociation() {
const entityKey = getCurrentEntityKey();
return entityKey && extensionSettings.presetManager.characterAssociations[entityKey] !== undefined;
}
/**
* Checks if the current character/group is associated with the currently active preset
* @returns {boolean} True if the current entity is associated with the active preset
*/
export function isAssociatedWithCurrentPreset() {
const entityKey = getCurrentEntityKey();
const activePresetId = extensionSettings.presetManager?.activePresetId;
if (!entityKey || !activePresetId) return false;
return extensionSettings.presetManager.characterAssociations[entityKey] === activePresetId;
}
/**
* Auto-switches to the preset associated with the current character/group
* Called when character changes. Falls back to default preset if no association.
* @returns {boolean} True if a preset was switched, false otherwise
*/
export function autoSwitchPresetForEntity() {
const associatedPresetId = getPresetForCurrentEntity();
// If there's a character-specific preset, use it
if (associatedPresetId && associatedPresetId !== extensionSettings.presetManager.activePresetId) {
// Check if the preset still exists
if (extensionSettings.presetManager.presets[associatedPresetId]) {
return loadPreset(associatedPresetId);
} else {
// Preset was deleted, remove the stale association
removePresetAssociationForCurrentEntity();
}
}
// No character association - fall back to default preset if set
if (!associatedPresetId) {
const defaultPresetId = extensionSettings.presetManager.defaultPresetId;
if (defaultPresetId &&
defaultPresetId !== extensionSettings.presetManager.activePresetId &&
extensionSettings.presetManager.presets[defaultPresetId]) {
return loadPreset(defaultPresetId);
}
}
return false;
}
/**
* Exports presets for sharing (without character associations)
* @param {string[]} presetIds - Array of preset IDs to export, or empty for all
* @returns {Object} Export data object
*/
export function exportPresets(presetIds = []) {
const presetsToExport = {};
const allPresets = extensionSettings.presetManager.presets;
// If no specific IDs provided, export all
const idsToExport = presetIds.length > 0 ? presetIds : Object.keys(allPresets);
for (const id of idsToExport) {
if (allPresets[id]) {
presetsToExport[id] = {
id: allPresets[id].id,
name: allPresets[id].name,
trackerConfig: allPresets[id].trackerConfig
};
}
}
return {
version: '1.0',
exportDate: new Date().toISOString(),
presets: presetsToExport
// Note: characterAssociations are intentionally NOT exported
};
}
/**
* Imports presets from an export file
* @param {Object} importData - The imported data object
* @param {boolean} overwrite - If true, overwrites existing presets with same name
* @returns {number} Number of presets imported
*/
export function importPresets(importData, overwrite = false) {
if (!importData.presets || typeof importData.presets !== 'object') {
throw new Error('Invalid import data: missing presets');
}
let importCount = 0;
const existingNames = new Set(
Object.values(extensionSettings.presetManager.presets).map(p => p.name.toLowerCase())
);
for (const [originalId, preset] of Object.entries(importData.presets)) {
if (!preset.name || !preset.trackerConfig) {
continue; // Skip invalid presets
}
let name = preset.name;
const nameLower = name.toLowerCase();
// Check for name collision
if (existingNames.has(nameLower)) {
if (overwrite) {
// Find and delete the existing preset with this name
for (const [existingId, existingPreset] of Object.entries(extensionSettings.presetManager.presets)) {
if (existingPreset.name.toLowerCase() === nameLower) {
delete extensionSettings.presetManager.presets[existingId];
break;
}
}
} else {
// Generate a unique name
let counter = 1;
while (existingNames.has(`${nameLower} (${counter})`)) {
counter++;
}
name = `${preset.name} (${counter})`;
}
}
// Create new preset with new ID
const newId = `preset_${Date.now()}_${importCount}`;
extensionSettings.presetManager.presets[newId] = {
id: newId,
name: name,
trackerConfig: JSON.parse(JSON.stringify(preset.trackerConfig))
};
existingNames.add(name.toLowerCase());
importCount++;
}
if (importCount > 0) {
saveSettings();
}
return importCount;
}
+92 -25
View File
@@ -10,7 +10,7 @@
* Extension settings - persisted to SillyTavern settings
*/
export let extensionSettings = {
settingsVersion: 3, // Version number for settings migrations (v3 = JSON format)
settingsVersion: 4, // Version number for settings migrations (v4 = FAB widgets enabled by default)
enabled: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
@@ -27,13 +27,21 @@ export let extensionSettings = {
customHtmlPrompt: '', // Custom HTML prompt text (empty = use default)
enableDialogueColoring: false, // Enable dialogue coloring prompt injection
customDialogueColoringPrompt: '', // Custom dialogue coloring prompt text (empty = use default)
enableDeceptionSystem: false, // Enable deception tracking with <lie> tags
customDeceptionPrompt: '', // Custom deception prompt text (empty = use default)
enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices
customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default)
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
weatherBackground: true, // Show weather effects in background (behind chat)
weatherForeground: false, // Show weather effects in foreground (on top of chat)
dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
showHtmlToggle: true, // Show Immersive HTML toggle in main panel
showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
showDeceptionToggle: true, // Show Deception System toggle in main panel
showCYOAToggle: true, // Show CYOA toggle in main panel
showSpotifyToggle: true, // Show Spotify Music toggle in main panel
showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel
@@ -42,7 +50,14 @@ export let extensionSettings = {
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
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
// History persistence settings - inject selected tracker data into historical messages
historyPersistence: {
enabled: false, // Master toggle for history persistence feature
messageCount: 5, // Number of messages to include (0 = all available)
injectionPosition: 'assistant_message_end', // 'user_message_end', 'assistant_message_end', 'extra_user_message', 'extra_assistant_message'
contextPreamble: '', // Optional custom preamble text (empty = use default short one)
sendAllEnabledOnRefresh: false // If true, sends all enabled stats from preset instead of only persistInHistory-enabled stats on Refresh RPG Info
},
panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
customColors: {
@@ -58,6 +73,27 @@ export let extensionSettings = {
top: 'calc(var(--topBarBlockSize) + 60px)',
right: '12px'
}, // Saved position for mobile FAB button
// Mobile FAB widget display options (8-position system around the button)
mobileFabWidgets: {
enabled: true, // Master toggle for FAB widgets
weatherIcon: { enabled: true, position: 0 }, // Weather emoji (☀️, 🌧️, etc.)
weatherDesc: { enabled: true, position: 1 }, // Weather description text
clock: { enabled: true, position: 2 }, // Current time display
date: { enabled: true, position: 3 }, // Date display
location: { enabled: true, position: 4 }, // Location name
stats: { enabled: true, position: 5 }, // All stats as compact numbers
attributes: { enabled: true, position: 6 } // Compact RPG attributes display
},
// Desktop strip widget display options (shown in collapsed panel strip)
desktopStripWidgets: {
enabled: true, // Master toggle for strip widgets (enabled by default)
weatherIcon: { enabled: true }, // Weather emoji (☀️, 🌧️, etc.)
clock: { enabled: true }, // Current time display
date: { enabled: true }, // Date display
location: { enabled: true }, // Location name
stats: { enabled: true }, // All stats as compact numbers
attributes: { enabled: true } // Compact RPG attributes display
},
userStats: JSON.stringify({
stats: [
{ id: 'health', name: 'Health', value: 100 },
@@ -89,47 +125,55 @@ export let extensionSettings = {
// Tracker customization configuration
trackerConfig: {
userStats: {
// Stats display mode: 'percentage' or 'number'
statsDisplayMode: 'percentage',
// Array of custom stats (allows add/remove/rename)
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'satiety', name: 'Satiety', enabled: true },
{ id: 'energy', name: 'Energy', enabled: true },
{ id: 'hygiene', name: 'Hygiene', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
{ id: 'health', name: 'Health', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'energy', name: 'Energy', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false, maxValue: 100 }
],
// RPG Attributes (customizable D&D-style attributes)
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
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 }
{ id: 'str', name: 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: 'WIS', enabled: true, persistInHistory: false },
{ id: 'cha', name: 'CHA', enabled: true, persistInHistory: false }
],
// Status section config
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions'] // User can edit what to track
customFields: ['Conditions'], // User can edit what to track
persistInHistory: false // Persist status in historical messages
},
// Optional skills field
skillsSection: {
enabled: false,
label: 'Skills', // User-editable
customFields: [] // Array of skill names
}
customFields: [], // Array of skill names
persistInHistory: false // Persist skills in historical messages
},
// Inventory persistence
inventoryPersistInHistory: false, // Persist inventory in historical messages
// Quests persistence
questsPersistInHistory: false // Persist quests in historical messages
},
infoBox: {
widgets: {
date: { enabled: true, format: 'Weekday, Month, Year' }, // Format options in UI
weather: { enabled: true },
temperature: { enabled: true, unit: 'C' }, // 'C' or 'F'
time: { enabled: true },
location: { enabled: true },
recentEvents: { enabled: true }
date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true }, // Date enabled by default for history
weather: { enabled: true, persistInHistory: true }, // Weather enabled by default for history
temperature: { enabled: true, unit: 'C', persistInHistory: false }, // 'C' or 'F'
time: { enabled: true, persistInHistory: true }, // Time enabled by default for history
location: { enabled: true, persistInHistory: true }, // Location enabled by default for history
recentEvents: { enabled: true, persistInHistory: false }
}
},
presentCharacters: {
@@ -159,14 +203,15 @@ export let extensionSettings = {
},
// Custom fields (appearance, demeanor, etc. - shown after relationship, separated by |)
customFields: [
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)' },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state' }
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false }
],
// Thoughts configuration (separate line)
thoughts: {
enabled: true,
name: 'Thoughts',
description: 'Internal monologue (in first person POV, up to three sentences long)'
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)',
persistInHistory: false
},
// Character stats toggle (optional feature)
characterStats: {
@@ -250,6 +295,18 @@ export let extensionSettings = {
recentEvents: false // Boolean for recent events widget lock
},
characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}})
},
// Preset management for tracker configurations
presetManager: {
// Map of preset ID to preset data (contains name and trackerConfig)
presets: {},
// Map of character/group entity to preset ID (e.g., "char_0": "preset_123", "group_abc": "preset_456")
// Note: This is stored separately and NOT exported with presets
characterAssociations: {},
// Currently active preset ID
activePresetId: null,
// Default preset ID (used when no character association exists)
defaultPresetId: null
}
};
@@ -308,6 +365,12 @@ export let isGenerating = false;
*/
export let isPlotProgression = false;
/**
* Flag indicating if we're actively expecting a new message from generation
* (as opposed to loading chat history)
*/
export let isAwaitingNewMessage = false;
/**
* Temporary storage for pending dice roll (not saved until user clicks "Save Roll")
*/
@@ -408,6 +471,10 @@ export function setIsPlotProgression(value) {
isPlotProgression = value;
}
export function setIsAwaitingNewMessage(value) {
isAwaitingNewMessage = value;
}
export function setPendingDiceRoll(roll) {
pendingDiceRoll = roll;
}
+3 -4
View File
@@ -2,6 +2,7 @@
"settings.language.label": "Language",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.extensionEnabled": "Enable RPG Companion",
"settings.note": "Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.",
"template.settingsTitle": "RPG Companion Settings",
@@ -26,15 +27,13 @@
"template.settingsModal.display.panelPositionOptions.right": "Right Sidebar",
"template.settingsModal.display.panelPositionOptions.left": "Left Sidebar",
"template.settingsModal.display.toggleAutoUpdate": "Auto-update after messages",
"template.settingsModal.display.showUserStats": "Show User Stats",
"template.settingsModal.display.toggleAutoUpdateNote": "Automatically refresh RPG info after each message.",
"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.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.",
"template.settingsModal.display.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.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory",
@@ -76,7 +75,7 @@
"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.clearCacheNote": "Clears committed and displayed tracker data for your currently active chat.",
"template.settingsModal.advanced.generationMode": "Generation Mode:",
"template.settingsModal.advanced.generationModeOptions.together": "Together with Main Generation",
"template.settingsModal.advanced.generationModeOptions.separate": "Separate Generation",
+236
View File
@@ -0,0 +1,236 @@
{
"settings.language.label": "Язык",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.extensionEnabled": "Включить RPG Companion",
"settings.note": "Включить или отключить расширение RPG Companion. Дополнительные настройки производятся непосредственно в панели приложения.",
"template.settingsTitle": "Настройки RPG Companion",
"template.settingsModal.themeTitle": "Тема",
"template.settingsModal.themeLabel": "Стиль:",
"template.settingsModal.themeOptions.default": "По умолчанию",
"template.settingsModal.themeOptions.sciFi": "Скай-фай (Synthwave)",
"template.settingsModal.themeOptions.fantasy": "Фэнтези (Rustic Parchment)",
"template.settingsModal.themeOptions.cyberpunk": "Киберпанк (Neon Grid)",
"template.settingsModal.themeOptions.custom": "Своя",
"template.settingsModal.themeOptions.custom.background": "Фон:",
"template.settingsModal.themeOptions.custom.accent": "Акцент:",
"template.settingsModal.themeOptions.custom.text": "Текст:",
"template.settingsModal.themeOptions.custom.highlight": "Подсветка:",
"template.settingsModal.theme.statBarLow": "Цвет полоски характеристики (Низкие значения):",
"template.settingsModal.theme.statBarLowNote": "Цвет при значении показателей 0%.",
"template.settingsModal.theme.statBarHigh": "Цвет полоски характеристики (Высокие значения):",
"template.settingsModal.theme.statBarHighNote": "Цвет при значении показателей 100%.",
"template.settingsModal.displayTitle": "Настройки отображения",
"template.settingsModal.displayNote": "Вы можете вкючить/отключить расширение RPG Companion во вкладке расширений для SillyTavern.",
"template.settingsModal.display.panelPosition": "Положение боковой панели:",
"template.settingsModal.display.panelPositionOptions.right": "Справа",
"template.settingsModal.display.panelPositionOptions.left": "Слева",
"template.settingsModal.display.toggleAutoUpdate": "Авто-обновление после ответа",
"template.settingsModal.display.toggleAutoUpdateNote": "Автоматически обновлять информацию в трекрере после каждого ответа.",
"template.settingsModal.display.showUserStats": "Показать Характеристики Игрока",
"template.settingsModal.display.showUserStatsNote": "Включить Характеристики Игрока, которые отслеживают статистику используемой персоны - характеристики, настроение, навыки и т.д.",
"template.settingsModal.display.showInfoBox": "Показывать Инфо-панель",
"template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.",
"template.settingsModal.display.showPresentCharacters": "Показывать персонажей",
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
"template.settingsModal.display.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь",
"template.settingsModal.display.showInventoryNote": "Отслеживайте переносимые предметы, одежду, хранимые вещи и активы.",
"template.settingsModal.display.showQuests": "Показывать задания",
"template.settingsModal.display.showQuestsNote": "Управляйте основными и дополнительными заданиями с целями.",
"template.settingsModal.display.showLockIcons": "Показывать значки блокировки/разблокировки трекеров",
"template.settingsModal.display.showLockIconsNote": "Отображать значки блокировки/разблокировки на элементах трекера, чтобы предотвратить их изменение ИИ.",
"template.settingsModal.display.showThoughtsInChat": "Показывать мысли",
"template.settingsModal.display.showThoughtsInChatNote": "Отображать мысли персонажей в виде всплывающих пузырьков рядом с их сообщениями.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Всегда показывать пузырь мыслей",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Автоматически раскрывать пузырь мыслей без предварительного нажатия на значок",
"template.settingsModal.display.enableAnimations": "Включить анимации",
"template.settingsModal.display.enableAnimationsNote": "Плавные переходы для характеристик, обновления контента и бросков кубиков.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Показывать переключатель Immersive HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Отображать кнопку переключения для включения/отключения HTML-форматирования в сообщениях.",
"template.settingsModal.display.showDialogueColoringToggle": "Показывать переключатель цветных диалогов",
"template.settingsModal.display.showDialogueColoringToggleNote": "Отображать кнопку переключения для включения/отключения цветного форматирования диалогов.",
"template.settingsModal.display.showSpotifyMusicToggle": "Показывать переключатель музыки Spotify",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Отображать музыкальный проигрыватель Spotify с предложенными ИИ треками, подходящими для сцены.",
"template.settingsModal.display.showSnowflakesToggle": "Показывать переключатель погодных эффектов",
"template.settingsModal.display.showDynamicWeatherToggle": "Показывать переключатель динамических погодных эффектов",
"template.settingsModal.display.showDynamicWeatherToggleNote": "Отображать кнопку переключения для включения/отключения анимированных погодных эффектов.",
"template.settingsModal.display.showNarratorMode": "Показывать переключатель режима рассказчика",
"template.settingsModal.display.showNarratorModeNote": "Отображать кнопку переключения для включения/отключения режима рассказчика (персонажи определяются из контекста).",
"template.settingsModal.display.showAutoAvatars": "Показывать переключатель автоматической генерации аватаров",
"template.settingsModal.display.showAutoAvatarsNote": "Отображать кнопку переключения для автоматической генерации аватаров для персонажей без изображений.",
"template.settingsModal.display.showRandomizedPlot": "Показывать переключатель случайного развития сюжета",
"template.settingsModal.display.showRandomizedPlotNote": "Отображать кнопку для генерации ИИ случайных подсказок для развития сюжета.",
"template.settingsModal.display.showNaturalPlot": "Показывать переключатель естественного развития сюжета",
"template.settingsModal.display.showNaturalPlotNote": "Отображать кнопку для контекстно-зависимых подсказок продолжения повествования.",
"template.settingsModal.display.showStartEncounter": "Показывать переключатель начала встречи",
"template.settingsModal.display.showStartEncounterNote": "Отображать кнопку для начала интерактивных боевых столкновений.",
"template.settingsModal.display.showDiceDisplay": "Показывать отображение броска кубиков",
"template.settingsModal.display.showDiceDisplayNote": "Отображать индикатор \"Последний бросок\" на панели.",
"template.mainPanel.autoAvatars": "Авто-аватары",
"template.settingsModal.advancedTitle": "Дополнительно",
"template.settingsModal.advanced.encounterHistoryDepth": "Глубина истории чата для боя:",
"template.settingsModal.advanced.encounterHistoryDepthNote": "Количество последних сообщений, включаемых при инициализации боя.",
"template.settingsModal.advanced.autoSaveCombatLogs": "Автосохранение журналов боя",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "Сохранять подробные журналы боя в файл для будущего использования и анализа.",
"template.settingsModal.advanced.clearCacheNote": "Очищает сохраненные и отображаемые данные трекеров для текущего активного чата.",
"template.settingsModal.advanced.generationMode": "Режим генерации:",
"template.settingsModal.advanced.generationModeOptions.together": "Вместе с основной генерацией",
"template.settingsModal.advanced.generationModeOptions.separate": "Отдельная генерация",
"template.settingsModal.advanced.generationModeNote": "Вместе: добавляет RPG-трекинг к основному ответу. Отдельно: генерирует RPG-данные отдельно (вручную или автоматически). Внешний: подключается напрямую к OpenAI-совместимому эндпоинту.",
"template.settingsModal.advanced.generationModeOptions.external": "Внешний API",
"template.settingsModal.advanced.externalApi.title": "Настройки внешнего API",
"template.settingsModal.advanced.externalApi.baseUrl": "Базовый URL API",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI-совместимый эндпоинт (например, OpenAI, OpenRouter, локальный сервер LLM).",
"template.settingsModal.advanced.externalApi.apiKey": "API-ключ",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Ваш API-ключ для внешнего сервиса.",
"template.settingsModal.advanced.externalApi.model": "Модель",
"template.settingsModal.advanced.externalApi.modelNote": "Идентификатор модели (например, gpt-4o-mini, claude-3-haiku, mistral-7b).",
"template.settingsModal.advanced.externalApi.maxTokens": "Максимальное количество токенов",
"template.settingsModal.advanced.externalApi.temperature": "Температура",
"template.settingsModal.advanced.externalApi.testConnection": "Тестировать соединение",
"template.settingsModal.advanced.contextMessages": "Контекстные сообщения:",
"template.settingsModal.advanced.contextMessagesNote": "Количество последних сообщений, включаемых в контекст.",
"template.settingsModal.advanced.useSeparatePreset": "Использовать модель, подключенную к пресету RPG Companion Trackers",
"template.settingsModal.advanced.useSeparatePresetNote": "При включении генерация трекеров будет использовать модель из пресета \"RPG Companion Trackers\" вместо основной модели API. Пресет будет автоматически переключаться во время генерации и восстанавливаться после нее. Выберите желаемую модель в этом пресете и убедитесь, что переключатель \"Bind presets to API connections\" включен (рядом с кнопками импорта/экспорта пресетов).",
"template.settingsModal.advanced.skipInjections": "Пропускать инъекции во время управляемых генераций:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Никогда не пропускать",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Только при запросах олицетворения",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Всегда для управляемых или тихих подсказок",
"template.settingsModal.advanced.skipInjectionsNote": "При установке расширение не будет внедрять подсказки трекеров, примеры или HTML-инструкции в соответствии с выбранным режимом при обнаружении управляемой генерации (через `instruct` или `quiet_prompt`). Полезно при использовании GuidedGenerations или аналогичных расширений.",
"template.settingsModal.advanced.customHtmlPromptTitle": "Пользовательская HTML-подсказка:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "Восстановить по умолчанию",
"template.settingsModal.advanced.customHtmlPromptNote": "Настройте HTML-подсказку, которая внедряется при включенной опции \"Enable Immersive HTML\". Подсказка по умолчанию показана выше - вы можете редактировать ее напрямую или полностью заменить. Нажмите \"Восстановить по умолчанию\" для сброса. Это влияет на все режимы генерации (together, separate и plot progression).",
"template.settingsModal.advanced.clearCache": "Очистить кэш расширения",
"template.settingsModal.advanced.resetFabPositions": "Сбросить позиции кнопок",
"template.settingsModal.advanced.resetFabPositionsNote": "Сбрасывает все плавающие кнопки действий (переключение, обновление, отладка) в позиции по умолчанию (сверху слева). Полезно, если кнопки находятся за пределами экрана.",
"template.trackerEditorModal.title": "Редактировать трекеры",
"template.trackerEditorModal.tabs.userStats": "Характеристики пользователя",
"template.trackerEditorModal.tabs.infoBox": "Инфо-панель",
"template.trackerEditorModal.tabs.presentCharacters": "Присутствующие персонажи",
"template.trackerEditorModal.buttons.reset": "Сбросить",
"template.trackerEditorModal.buttons.cancel": "Отмена",
"template.trackerEditorModal.buttons.save": "Сохранить и применить",
"template.trackerEditorModal.buttons.export": "Экспорт",
"template.trackerEditorModal.buttons.import": "Импорт",
"template.trackerEditorModal.messages.exportSuccess": "Шаблон трекеров успешно экспортирован!",
"template.trackerEditorModal.messages.exportError": "Не удалось экспортировать шаблон трекеров. Проверьте консоль для получения подробностей.",
"template.trackerEditorModal.messages.importSuccess": "Шаблон трекеров успешно импортирован!",
"template.trackerEditorModal.messages.importError": "Не удалось импортировать шаблон трекеров",
"template.trackerEditorModal.messages.importConfirm": "Это заменит текущую конфигурацию трекеров. Продолжить?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "Пользовательские характеристики",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "Добавить пользовательскую характеристику",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG-атрибуты",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "Включить раздел RPG-атрибутов",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "Всегда включать атрибуты в подсказку",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "Если отключено, атрибуты отправляются только при активном броске кубиков.",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "Добавить атрибут",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "Раздел статуса",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "Включить раздел статуса",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "Показывать эмодзи настроения",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "Поля статуса (через запятую):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "Раздел навыков",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "Включить раздел навыков",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "Метка навыков:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "Список навыков (через запятую):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "Виджеты",
"template.trackerEditorModal.infoBoxTab.dateWidget": "Дата",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "Погода",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "Температура",
"template.trackerEditorModal.infoBoxTab.timeWidget": "Время",
"template.trackerEditorModal.infoBoxTab.locationWidget": "Местоположение",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "Недавние события",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "Поля статуса отношений",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "Включить поля статуса отношений",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "Определите типы отношений с соответствующими эмодзи, отображаемыми на портретах персонажей.",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "Новое отношение",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "Поля внешности/поведения",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "Поля, отображаемые под именем персонажа.",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "Добавить пользовательское поле",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "Настройки мыслей",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "Включить мысли персонажей",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "Метка мыслей:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "Инструкция для ИИ:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Характеристики персонажей",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Отслеживать характеристики персонажей",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Создавайте характеристики для отслеживания для каждого персонажа (отображаются в виде цветных полос).",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Добавить характеристику персонажа",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "Последний бросок:",
"template.mainPanel.clearLastRoll": "Очистить последний бросок",
"template.mainPanel.immersiveHtml": "Immersive HTML",
"template.mainPanel.coloredDialogues": "Цветные диалоги",
"template.mainPanel.spotifyMusic": "Музыка Spotify",
"template.mainPanel.snowflakesEffect": "Эффект снежинок",
"template.mainPanel.dynamicWeatherEffects": "Динамическая погода",
"template.mainPanel.narratorMode": "Режим рассказчика",
"template.mainPanel.refreshRpgInfo": "Обновить RPG-информацию",
"template.mainPanel.updating": "Обновление...",
"template.mainPanel.editTrackersButton": "Редактировать трекеры",
"template.mainPanel.settingsButton": "Настройки",
"global.none": "Нет",
"global.add": "Добавить",
"global.cancel": "Отмена",
"global.listView": "Вид списка",
"global.gridView": "Вид сетки",
"global.save": "Сохранить",
"global.status": "Статус",
"global.inventory": "Инвентарь",
"global.quests": "Задания",
"global.info": "Информация",
"infobox.noData.title": "Данных пока нет",
"infobox.noData.instruction": "Сгенерируйте новый ответ в ролевой игре или переключитесь на \"Отдельную генерацию\" в Настройках, чтобы получить доступ и нажать кнопку \"Обновить RPG-информацию\"",
"infobox.recentEvents.title": "Недавние события",
"infobox.recentEvents.addEventPlaceholder": "Добавить событие...",
"inventory.section.onPerson": "При себе",
"inventory.section.clothing": "Одежда",
"inventory.section.stored": "Хранимое",
"inventory.section.assets": "Активы",
"inventory.onPerson.empty": "Нет переносимых предметов",
"inventory.onPerson.title": "Предметы, которые сейчас в инвентаре",
"inventory.onPerson.addItemButton": "Добавить предмет",
"inventory.onPerson.addItemPlaceholder": "Введите название предмета...",
"inventory.clothing.empty": "Ничего не надето",
"inventory.clothing.title": "Одежда и броня",
"inventory.clothing.addItemButton": "Добавить одежду",
"inventory.clothing.addItemPlaceholder": "Введите элемент одежды...",
"inventory.stored.title": "Места хранения",
"inventory.stored.addLocationButton": "Добавить место",
"inventory.stored.addLocationPlaceholder": "Введите название места...",
"inventory.stored.saveButton": "Сохранить",
"inventory.stored.empty": "Пока нет мест хранения. Нажмите \"Добавить место\", чтобы создать.",
"inventory.stored.noItems": "Здесь нет хранимых предметов",
"inventory.stored.addItemToLocationPlaceholder": "Введите название предмета...",
"inventory.stored.addItemButton": "Добавить предмет",
"inventory.stored.confirmRemoveLocationMessage": "Удалить \"${location}\"? Это удалит все предметы, хранящиеся там.",
"inventory.stored.confirmRemoveLocationConfirmButton": "Подтвердить",
"inventory.assets.empty": "Нет активов",
"inventory.assets.title": "Транспорт, недвижимость и крупные владения",
"inventory.assets.addAssetModalTitle": "Добавить актив",
"inventory.assets.addAssetButton": "Добавить актив",
"inventory.assets.addAssetPlaceholder": "Введите название актива...",
"inventory.assets.description": "Активы включают транспортные средства (автомобили, мотоциклы), недвижимость (дома, квартиры) и крупное оборудование (инструменты для мастерской, специальные предметы).",
"quests.section.main": "Основное задание",
"quests.section.optional": "Дополнительные задания",
"quests.main.title": "Основные задания",
"quests.main.addQuestButton": "Добавить задание",
"quests.main.addQuestPlaceholder": "Введите название основного задания...",
"quests.main.empty": "Нет активных основных заданий",
"quests.main.hint": "Основное задание представляет вашу главную цель в истории.",
"quests.optional.title": "Дополнительные задания",
"quests.optional.addQuestButton": "Добавить задание",
"quests.optional.addQuestPlaceholder": "Введите название дополнительного задания...",
"quests.optional.empty": "Нет активных дополнительных заданий",
"quests.optional.hint": "Дополнительные задания - это побочные цели, которые дополняют основную историю.",
"checkpoint.setChapterStart": "Установить начало главы",
"checkpoint.clearChapterStart": "Очистить начало главы",
"checkpoint.indicator": "Начало главы",
"checkpoint.tooltip": "Сообщения до этой точки исключаются из контекста",
"musicPlayer.title": "Музыка сцены",
"musicPlayer.noMusic": "ИИ будет предлагать музыку, когда это уместно для сцены",
"errors.parsingError": "Ошибка парсинга RPG Companion Trackers! Модель вернула неправильный формат. Если проблема сохраняется, рассмотрите возможность смены модели для генераций.",
"settings.recommendedModels.title": "Рекомендуемые модели",
"settings.recommendedModels.description": "Для правильной работы расширения **не рекомендуется использовать модели с базой обчучения ниже 20B, особенно если они старые.** Оно лучше всего работает с современными моделями, такими как Deepseek, Claude, GPT или Gemini."
}
+1
View File
@@ -2,6 +2,7 @@
"settings.language.label": "語言",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.extensionEnabled": "啟用 RPG Companion",
"settings.note": "切換開關以啟用/停用 RPG Companion。其他設定可在面板內配置。",
"template.settingsTitle": "RPG Companion 設定",
+3
View File
@@ -8,6 +8,7 @@ import {
$userStatsContainer
} from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Sets up event listeners for classic stat +/- buttons using delegation.
@@ -25,6 +26,7 @@ export function setupClassicStatsButtons() {
saveChatData();
// Update only the specific stat value, not the entire stats panel
$(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]);
updateFabWidgets();
}
});
@@ -37,6 +39,7 @@ export function setupClassicStatsButtons() {
saveChatData();
// Update only the specific stat value, not the entire stats panel
$(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]);
updateFabWidgets();
}
});
}
+15 -1
View File
@@ -5,7 +5,7 @@
import { togglePlotButtons } from '../ui/layout.js';
import { extensionSettings, setIsPlotProgression } from '../../core/state.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT } from '../generation/promptBuilder.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_CYOA_PROMPT } from '../generation/promptBuilder.js';
import { Generate } from '../../../../../../../script.js';
/**
@@ -121,6 +121,20 @@ export async function sendPlotProgression(type) {
prompt += '\n\n' + dialogueColoringPromptText;
}
// Add Deception System prompt if enabled
if (extensionSettings.enableDeceptionSystem) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
prompt += '\n\n' + deceptionPromptText;
}
// Add CYOA prompt if enabled
if (extensionSettings.enableCYOA) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
prompt += '\n\n' + cyoaPromptText;
}
// Set flag to indicate we're doing plot progression
// This will be used by onMessageReceived to clear the prompt after generation completes
setIsPlotProgression(true);
+30 -15
View File
@@ -34,6 +34,8 @@ import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
import { i18n } from '../../core/i18n.js';
import { generateAvatarsForCharacters } from '../features/avatarGenerator.js';
import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js';
import { updateStripWidgets } from '../ui/desktop.js';
// Store the original preset name to restore after tracker generation
let originalPresetName = null;
@@ -235,11 +237,14 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
try {
setIsGenerating(true);
setFabLoadingState(true); // Show spinning FAB on mobile
// Update button to show "Updating..." state
const $updateBtn = $('#rpg-manual-update');
const $stripRefreshBtn = $('#rpg-strip-refresh');
const updatingText = i18n.getTranslation('template.mainPanel.updating') || 'Updating...';
$updateBtn.html(`<i class="fa-solid fa-spinner fa-spin"></i> ${updatingText}`).prop('disabled', true);
$stripRefreshBtn.html('<i class="fa-solid fa-spinner fa-spin"></i>').prop('disabled', true);
const prompt = await generateSeparateUpdatePrompt();
@@ -302,21 +307,6 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
lastGeneratedData.characterThoughts = parsedData.characterThoughts;
}
// When saveTrackerHistory is enabled, store tracker data on the user's message too
// This allows scrolling through history and seeing trackers at each point
if (extensionSettings.saveTrackerHistory && lastMessage && lastMessage.is_user) {
if (!lastMessage.extra) {
lastMessage.extra = {};
}
lastMessage.extra.rpg_companion_data = {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts,
timestamp: Date.now()
};
// console.log('[RPG Companion] 💾 Stored tracker data on user message for history');
}
// Also store on assistant message if present (existing behavior)
if (lastMessage && !lastMessage.is_user) {
if (!lastMessage.extra) {
@@ -391,11 +381,16 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
}
} finally {
setIsGenerating(false);
setFabLoadingState(false); // Stop spinning FAB on mobile
updateFabWidgets(); // Update FAB widgets with new data
updateStripWidgets(); // Update strip widgets with new data
// Restore button to original state
const $updateBtn = $('#rpg-manual-update');
const $stripRefreshBtn = $('#rpg-strip-refresh');
const refreshText = i18n.getTranslation('template.mainPanel.refreshRpgInfo') || 'Refresh RPG Info';
$updateBtn.html(`<i class="fa-solid fa-sync"></i> ${refreshText}`).prop('disabled', false);
$stripRefreshBtn.html('<i class="fa-solid fa-sync"></i>').prop('disabled', false);
// Reset the flag after tracker generation completes
// This ensures the flag persists through both main generation AND tracker generation
@@ -416,6 +411,26 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
function parseCharactersFromThoughts(characterThoughtsData) {
if (!characterThoughtsData) return [];
// Try parsing as JSON first (current format)
try {
const parsed = typeof characterThoughtsData === 'string'
? JSON.parse(characterThoughtsData)
: characterThoughtsData;
// Handle both {characters: [...]} and direct array formats
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) {
// Extract names from JSON character objects
return charactersArray
.map(char => char.name)
.filter(name => name && name.toLowerCase() !== 'unavailable');
}
} catch (e) {
// Not JSON, fall back to text parsing
}
// Fallback: Parse text format (legacy)
const lines = characterThoughtsData.split('\n');
const characters = [];
+70 -14
View File
@@ -121,7 +121,7 @@ export async function buildEncounterInitPrompt() {
// console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length });
if (worldInfoString && worldInfoString.trim()) {
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
// console.log('[RPG Companion] ✅ Added world info from getWorldInfoPrompt');
@@ -258,6 +258,7 @@ export async function buildEncounterInitPrompt() {
initInstruction += `The combat starts now.\n\n`;
initInstruction += `Based on everything above, generate the initial combat state. Analyze who is in the party fighting alongside ${userName} (if anyone), and who the enemies are. Replace placeholders in [brackets] and X with actual values. Return ONLY a JSON object with the following structure:\n\n`;
initInstruction += `FORMAT:\n`;
initInstruction += `{\n`;
initInstruction += ` "party": [\n`;
initInstruction += ` {\n`;
@@ -268,7 +269,7 @@ export async function buildEncounterInitPrompt() {
initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`;
initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`;
initInstruction += ` ],\n`;
initInstruction += ` "items": ["Item1", "Item2"],\n`;
initInstruction += ` "items": ["Item Name x3", "Another Item x1"],\n`;
initInstruction += ` "statuses": [],\n`;
initInstruction += ` "isPlayer": true\n`;
initInstruction += ` }\n`;
@@ -302,11 +303,14 @@ export async function buildEncounterInitPrompt() {
initInstruction += ` - "single-target": Can only target one character (enemy or ally)\n`;
initInstruction += ` - "AoE": Area of Effect - targets all enemies, but some AoE attacks (like storms, explosions) can also harm allies if the attack is indiscriminate\n`;
initInstruction += ` - "both": Player can choose to target a single enemy OR use as AoE\n`;
initInstruction += `- For items array: Include quantities using format "Item Name xN" (e.g., "Health Potion x3", "Bomb x1")\n`;
initInstruction += ` - If only one item exists, you can use "Item Name x1" or just "Item Name"\n`;
initInstruction += ` - Items will be consumed when used - the quantity will decrease in future turns\n`;
initInstruction += `- Statuses array: May start empty, but don't have to if characters applied them before the combat\n`;
initInstruction += ` - Each status has a format: {"name": "Status Name", "emoji": "💀", "duration": X}\n`;
initInstruction += ` - Examples: Poisoned (🧪), Burning (🔥), Blessed (✨), Stunned (💫), Weakened (⬇️), Strengthened (⬆️)\n\n`;
initInstruction += `The styleNotes object will be used to visually style the combat window - choose ONE value from each category that best fits the environment described in the chat history.\n\n`;
initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory. Set HP based on their current Health stat if available.\n\n`;
initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory WITH QUANTITIES (e.g., "Health Potion x3"). Set HP based on their current Health stat if available.\n\n`;
initInstruction += `Ensure all party members and enemies have realistic HP values based on the setting and their descriptions. Return ONLY the JSON object, no other text.`;
// Only add the instruction if it has meaningful content
@@ -364,7 +368,7 @@ export async function buildCombatActionPrompt(action, combatStats) {
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) {
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
@@ -483,12 +487,25 @@ export async function buildCombatActionPrompt(action, combatStats) {
stateMessage += `Party Members:\n`;
combatStats.party.forEach(member => {
stateMessage += `- ${member.name}${member.isPlayer ? ' (Player)' : ''}: ${member.hp}/${member.maxHp} HP\n`;
if (member.attacks && member.attacks.length > 0) {
stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (member.items && member.items.length > 0) {
stateMessage += ` Items: ${member.items.join(', ')}\n`;
// For the player, use playerActions if available, otherwise fall back to member data
if (member.isPlayer && currentEncounter.playerActions) {
if (currentEncounter.playerActions.attacks && currentEncounter.playerActions.attacks.length > 0) {
stateMessage += ` Attacks: ${currentEncounter.playerActions.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (currentEncounter.playerActions.items && currentEncounter.playerActions.items.length > 0) {
stateMessage += ` Items: ${currentEncounter.playerActions.items.join(', ')}\n`;
}
} else {
// For non-player party members, use their own data
if (member.attacks && member.attacks.length > 0) {
stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (member.items && member.items.length > 0) {
stateMessage += ` Items: ${member.items.join(', ')}\n`;
}
}
if (member.statuses && member.statuses.length > 0) {
const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name));
if (validStatuses.length > 0) {
@@ -515,11 +532,39 @@ export async function buildCombatActionPrompt(action, combatStats) {
});
stateMessage += `\n${userName}'s Action: ${action}\n\n`;
stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment:\n`;
stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment.\n\n`;
stateMessage += `IMPORTANT - Update ${userName}'s attacks and items arrays based on what happens in combat:\n`;
stateMessage += `- ${userName}'s action is already specified above - do NOT regenerate it. Only update ${userName}'s attacks/items arrays if their action consumed resources (used item, lost ability, etc.).\n`;
stateMessage += `- If they use an item, decrement its quantity ("Health Potion x3" becomes "Health Potion x2"). If quantity reaches 0, remove the item entirely.\n`;
stateMessage += `- If they gain or lose an ability due to status effects, add or remove it from their attacks array.\n`;
stateMessage += ` Examples: Disarmed → remove weapon attacks. Bound → remove all attacks or set to []. Freed → restore attacks.\n`;
stateMessage += `- If they pick up a weapon/item during combat, add it to their items or attacks array.\n`;
stateMessage += `- If environmental changes enable new actions (near water → "Splash Attack"), add them. If they disable actions (fire goes out → remove "Ignite"), remove them.\n`;
stateMessage += `- Status effects should persist and decrease duration each turn. Remove statuses when duration reaches 0.\n\n`;
stateMessage += `FORMAT:\n`;
stateMessage += `{\n`;
stateMessage += ` "combatStats": {\n`;
stateMessage += ` "party": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }],\n`;
stateMessage += ` "enemies": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }]\n`;
stateMessage += ` "party": [\n`;
stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}],\n`;
stateMessage += ` "isPlayer": true|false\n`;
stateMessage += ` }\n`;
stateMessage += ` ],\n`;
stateMessage += ` "enemies": [\n`;
stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}]\n`;
stateMessage += ` }\n`;
stateMessage += ` ]\n`;
stateMessage += ` },\n`;
stateMessage += ` "playerActions": {\n`;
stateMessage += ` "attacks": [{"name": "Attack", "type": "single-target|AoE|both"}],\n`;
stateMessage += ` "items": ["Item Name x3", "Another Item x1"]\n`;
stateMessage += ` },\n`;
stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`;
stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`;
@@ -587,7 +632,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) {
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
@@ -659,7 +704,9 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
summaryMessage += `\n\nProvide a narrative summary of the entire fight in a way that fits the style from the chat history. Start with [FIGHT CONCLUDED] on the first line, then provide the description.\n\n`;
summaryMessage += `Write with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`;
summaryMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. If you last started with a narration, begin this one with dialogue; if with an action, switch to an internal thought. 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. Minimize asterisks, ellipses, and em-dashes. Explicit content is allowed. Never end on handover cues; finish naturally.\n\n`;
summaryMessage += `Express ${userName}'s actions and dialogue using indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help"). The summary should be 2-4 paragraphs and capture the essence of the battle.\n\n`;
summaryMessage += `Dialogue Guidelines:\n`;
summaryMessage += `- Include ALL dialogue lines spoken by enemies and NPC party members during the encounter in direct quotes.\n`;
summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`;
// If in Together mode and trackers are enabled, add tracker update instructions
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) {
@@ -721,6 +768,12 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
*/
export function parseEncounterJSON(response) {
try {
// Ensure response is a string
if (!response || typeof response !== 'string') {
console.error('[RPG Companion] parseEncounterJSON received non-string input:', typeof response);
return null;
}
// Remove code blocks if present
let cleaned = response.trim();
@@ -736,6 +789,9 @@ export function parseEncounterJSON(response) {
if (firstBrace !== -1 && lastBrace !== -1) {
cleaned = cleaned.substring(firstBrace, lastBrace + 1);
} else {
console.error('[RPG Companion] No JSON object found in response');
return null;
}
// Try to parse directly first
+607 -12
View File
@@ -4,15 +4,13 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { setExtensionPrompt, extension_prompt_types, extension_prompt_roles } from '../../../../../../../script.js';
import { setExtensionPrompt, extension_prompt_types, extension_prompt_roles, eventSource, event_types } from '../../../../../../../script.js';
import {
extensionSettings,
committedTrackerData,
lastGeneratedData,
isGenerating,
lastActionWasSwipe,
setLastActionWasSwipe,
setIsGenerating
lastActionWasSwipe
} from '../../core/state.js';
import { evaluateSuppression } from './suppression.js';
import { parseUserStats } from './parser.js';
@@ -20,19 +18,518 @@ import {
generateTrackerExample,
generateTrackerInstructions,
generateContextualSummary,
formatHistoricalTrackerData,
DEFAULT_HTML_PROMPT,
DEFAULT_DIALOGUE_COLORING_PROMPT,
DEFAULT_DECEPTION_PROMPT,
DEFAULT_CYOA_PROMPT,
DEFAULT_SPOTIFY_PROMPT,
SPOTIFY_FORMAT_INSTRUCTION
} from './promptBuilder.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
// Track suppression state for event handler
let currentSuppressionState = false;
// 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;
// Store context map for prompt injection (used by event handlers)
let pendingContextMap = new Map();
// Flag to track if injection already happened in BEFORE_COMBINE
let historyInjectionDone = false;
/**
* Builds a map of historical context data from ST chat messages with rpg_companion_swipes data.
* Returns a map keyed by message index with formatted context strings.
* The index stored depends on the injection position setting.
*
* @returns {Map<number, string>} Map of target message index to formatted context string
*/
function buildHistoricalContextMap() {
const historyPersistence = extensionSettings.historyPersistence;
if (!historyPersistence || !historyPersistence.enabled) {
return new Map();
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length < 2) {
return new Map();
}
const trackerConfig = extensionSettings.trackerConfig;
const userName = context.name1;
const position = historyPersistence.injectionPosition || 'assistant_message_end';
const contextMap = new Map();
// Determine how many messages to include (0 = all available)
const messageCount = historyPersistence.messageCount || 0;
const maxMessages = messageCount === 0 ? chat.length : Math.min(messageCount, chat.length);
// Find the last assistant message - this is the one that gets current context via setExtensionPrompt
// We should NOT add historical context to it when injecting into assistant messages
// But when injecting into user messages, we DO need to process it to get context for the preceding user message
let lastAssistantIndex = -1;
for (let i = chat.length - 1; i >= 0; i--) {
if (!chat[i].is_user && !chat[i].is_system) {
lastAssistantIndex = i;
break;
}
}
// Iterate through messages to find those with tracker data
// For user_message_end: start from the last assistant message (we need its context for the preceding user message)
// For assistant_message_end: start from before the last assistant message (it gets current context via setExtensionPrompt)
let processedCount = 0;
const startIndex = position === 'user_message_end'
? lastAssistantIndex
: (lastAssistantIndex > 0 ? lastAssistantIndex - 1 : chat.length - 2);
for (let i = startIndex; i >= 0 && (messageCount === 0 || processedCount < maxMessages); i--) {
const message = chat[i];
// Skip system messages
if (message.is_system) {
continue;
}
// Only assistant messages have rpg_companion_swipes data
if (message.is_user) {
continue;
}
// Get the rpg_companion_swipes data for current swipe
// Data can be in two places:
// 1. message.extra.rpg_companion_swipes (current session, before save)
// 2. message.swipe_info[swipeId].extra.rpg_companion_swipes (loaded from file)
const currentSwipeId = message.swipe_id || 0;
let swipeData = message.extra?.rpg_companion_swipes;
// If not in message.extra, check swipe_info
if (!swipeData && message.swipe_info && message.swipe_info[currentSwipeId]) {
swipeData = message.swipe_info[currentSwipeId].extra?.rpg_companion_swipes;
}
if (!swipeData) {
continue;
}
const trackerData = swipeData[currentSwipeId];
if (!trackerData) {
continue;
}
// Format the historical tracker data using the shared function
const formattedContext = formatHistoricalTrackerData(trackerData, trackerConfig, userName);
if (!formattedContext) {
continue;
}
// Build the context wrapper
const preamble = historyPersistence.contextPreamble || 'Context for that moment:';
const wrappedContext = `\n${preamble}\n${formattedContext}`;
// Determine which message index to store based on injection position
let targetIndex = i; // Default: the assistant message itself
if (position === 'user_message_end') {
// Find the preceding user message before this assistant message
// This is the user message that prompted this assistant response
for (let j = i - 1; j >= 0; j--) {
if (chat[j].is_user && !chat[j].is_system) {
targetIndex = j;
break;
}
}
// If no user message found before, skip this one
if (targetIndex === i) {
continue;
}
}
// For assistant_message_end, extra_user_message, extra_assistant_message:
// We inject into the assistant message itself (for now - extra messages handled differently)
// Store the context keyed by target index
// If multiple assistant messages map to the same user message, append
if (contextMap.has(targetIndex)) {
contextMap.set(targetIndex, contextMap.get(targetIndex) + wrappedContext);
} else {
contextMap.set(targetIndex, wrappedContext);
}
processedCount++;
}
return contextMap;
}
/**
* Prepares historical context for injection into prompts.
* This builds the context map and stores it for use by prompt event handlers.
* Does NOT modify the original chat messages.
*/
function prepareHistoricalContextInjection() {
const historyPersistence = extensionSettings.historyPersistence;
if (!historyPersistence || !historyPersistence.enabled) {
pendingContextMap = new Map();
return;
}
if (currentSuppressionState || !extensionSettings.enabled) {
pendingContextMap = new Map();
return;
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length < 2) {
pendingContextMap = new Map();
historyInjectionDone = false;
return;
}
// Build and store the context map for use by prompt handlers
pendingContextMap = buildHistoricalContextMap();
historyInjectionDone = false; // Reset flag for new generation
}
/**
* Finds the best match position for message content in the prompt.
* Tries full content first, then progressively smaller suffixes.
*
* @param {string} prompt - The prompt to search in
* @param {string} messageContent - The message content to find
* @returns {{start: number, end: number}|null} - Position info or null if not found
*/
function findMessageInPrompt(prompt, messageContent) {
if (!messageContent || !prompt) {
return null;
}
// Try to find the full content first
let searchIndex = prompt.lastIndexOf(messageContent);
if (searchIndex !== -1) {
return { start: searchIndex, end: searchIndex + messageContent.length };
}
// If full content not found, try last N characters with progressively smaller chunks
// This handles cases where messages are truncated in the prompt
const searchLengths = [500, 300, 200, 100, 50];
for (const len of searchLengths) {
if (messageContent.length <= len) {
continue;
}
const searchContent = messageContent.slice(-len);
searchIndex = prompt.lastIndexOf(searchContent);
if (searchIndex !== -1) {
return { start: searchIndex, end: searchIndex + searchContent.length };
}
}
return null;
}
/**
* Injects historical context into a text completion prompt string.
* Searches for message content in the prompt and appends context after matches.
*
* @param {string} prompt - The text completion prompt
* @returns {string} - The modified prompt with injected context
*/
function injectContextIntoTextPrompt(prompt) {
if (pendingContextMap.size === 0) {
return prompt;
}
const context = getContext();
const chat = context.chat;
let modifiedPrompt = prompt;
let injectedCount = 0;
// Sort by message index descending so we inject from end to start
// This prevents position shifts from affecting earlier injections
const sortedEntries = Array.from(pendingContextMap.entries()).sort((a, b) => b[0] - a[0]);
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of sortedEntries) {
const message = chat[msgIdx];
if (!message || typeof message.mes !== 'string') {
continue;
}
// Find the message content in the prompt
const position = findMessageInPrompt(modifiedPrompt, message.mes);
if (!position) {
// Message not found in prompt (might be truncated or not included)
console.debug(`[RPG Companion] Could not find message ${msgIdx} in prompt for context injection`);
continue;
}
// Insert the context after the message content
modifiedPrompt = modifiedPrompt.slice(0, position.end) + ctxContent + modifiedPrompt.slice(position.end);
injectedCount++;
}
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} positions in text prompt`);
}
return modifiedPrompt;
}
/**
* Injects historical context into a chat completion message array.
* Modifies the content of messages in the array directly.
*
* @param {Array} chatMessages - The chat completion message array
* @returns {Array} - The modified message array with injected context
*/
function injectContextIntoChatPrompt(chatMessages) {
if (pendingContextMap.size === 0 || !Array.isArray(chatMessages)) {
return chatMessages;
}
const context = getContext();
const chat = context.chat;
let injectedCount = 0;
// Process each message that needs context injection
for (const [msgIdx, ctxContent] of pendingContextMap) {
const originalMessage = chat[msgIdx];
if (!originalMessage || typeof originalMessage.mes !== 'string') {
continue;
}
const messageContent = originalMessage.mes;
// Find this message in the chat completion array by matching content
// Try full content first, then progressively smaller suffixes
let found = false;
for (const promptMsg of chatMessages) {
if (!promptMsg.content || typeof promptMsg.content !== 'string') {
continue;
}
// Try full content match
if (promptMsg.content.includes(messageContent)) {
promptMsg.content = promptMsg.content + ctxContent;
injectedCount++;
found = true;
break;
}
// Try suffix matches for truncated messages
const searchLengths = [500, 300, 200, 100, 50];
for (const len of searchLengths) {
if (messageContent.length <= len) {
continue;
}
const searchContent = messageContent.slice(-len);
if (promptMsg.content.includes(searchContent)) {
promptMsg.content = promptMsg.content + ctxContent;
injectedCount++;
found = true;
break;
}
}
if (found) {
break;
}
}
if (!found) {
console.debug(`[RPG Companion] Could not find message ${msgIdx} in chat prompt for context injection`);
}
}
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} messages in chat prompt`);
}
return chatMessages;
}
/**
* Injects historical context into finalMesSend message array (text completion).
* Iterates through chat and finalMesSend in order, matching by content to skip injected messages.
*
* @param {Array} finalMesSend - The array of message objects {message: string, extensionPrompts: []}
* @returns {number} - Number of injections made
*/
function injectContextIntoFinalMesSend(finalMesSend) {
if (pendingContextMap.size === 0 || !Array.isArray(finalMesSend) || finalMesSend.length === 0) {
return 0;
}
const context = getContext();
const chat = context.chat;
if (!chat || chat.length === 0) {
return 0;
}
let injectedCount = 0;
// Build a map from chat index to finalMesSend index by matching content in order
// This handles injected messages (author's note, OOC, etc.) that exist in finalMesSend but not in chat
const chatToMesSendMap = new Map();
let mesSendIdx = 0;
for (let chatIdx = 0; chatIdx < chat.length && mesSendIdx < finalMesSend.length; chatIdx++) {
const chatMsg = chat[chatIdx];
if (!chatMsg || chatMsg.is_system) {
continue;
}
const chatContent = chatMsg.mes || '';
// Look for this chat message in finalMesSend starting from current position
// Skip any finalMesSend entries that don't match (they're injected content)
while (mesSendIdx < finalMesSend.length) {
const mesSendObj = finalMesSend[mesSendIdx];
if (!mesSendObj || !mesSendObj.message) {
mesSendIdx++;
continue;
}
// Check if this finalMesSend message contains the chat content
// Use a substring match since instruct formatting adds prefixes/suffixes
// Match with sufficient content (first 50 chars or full message if shorter)
const matchContent = chatContent.length > 50
? chatContent.substring(0, 50)
: chatContent;
if (matchContent && mesSendObj.message.includes(matchContent)) {
// Found a match - record the mapping
chatToMesSendMap.set(chatIdx, mesSendIdx);
mesSendIdx++;
break;
}
// This finalMesSend entry doesn't match - it's injected content, skip it
mesSendIdx++;
}
}
// Now inject context using the map
for (const [chatIdx, ctxContent] of pendingContextMap) {
const targetMesSendIdx = chatToMesSendMap.get(chatIdx);
if (targetMesSendIdx === undefined) {
console.debug(`[RPG Companion] Chat message ${chatIdx} not found in finalMesSend mapping`);
continue;
}
const mesSendObj = finalMesSend[targetMesSendIdx];
if (!mesSendObj || !mesSendObj.message) {
continue;
}
// Append context to this message
mesSendObj.message = mesSendObj.message + ctxContent;
injectedCount++;
console.debug(`[RPG Companion] Injected context for chat[${chatIdx}] into finalMesSend[${targetMesSendIdx}]`);
}
return injectedCount;
}
/**
* Event handler for GENERATE_BEFORE_COMBINE_PROMPTS (text completion).
* Injects historical context into the finalMesSend array before prompt combination.
* This is more reliable than post-combine string searching.
*
* @param {Object} eventData - Event data with finalMesSend and other properties
*/
function onGenerateBeforeCombinePrompts(eventData) {
if (!eventData || !Array.isArray(eventData.finalMesSend)) {
return;
}
// Skip for OpenAI (uses chat completion)
if (eventData.api === 'openai') {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
const injectedCount = injectContextIntoFinalMesSend(eventData.finalMesSend);
if (injectedCount > 0) {
console.log(`[RPG Companion] Injected historical context into ${injectedCount} messages in finalMesSend`);
historyInjectionDone = true; // Mark as done to prevent double injection
}
}
/**
* Event handler for GENERATE_AFTER_COMBINE_PROMPTS (text completion).
* This is now a backup/fallback - primary injection happens in BEFORE_COMBINE.
*
* @param {Object} eventData - Event data with prompt property
*/
function onGenerateAfterCombinePrompts(eventData) {
if (!eventData || typeof eventData.prompt !== 'string') {
return;
}
if (eventData.dryRun) {
return;
}
// Skip if injection already happened in BEFORE_COMBINE
if (historyInjectionDone) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
// Fallback injection for edge cases where BEFORE_COMBINE didn't work
console.log('[RPG Companion] Using fallback string-based injection (AFTER_COMBINE)');
eventData.prompt = injectContextIntoTextPrompt(eventData.prompt);
}
/**
* Event handler for CHAT_COMPLETION_PROMPT_READY.
* Injects historical context into the chat message array.
*
* @param {Object} eventData - Event data with chat property
*/
function onChatCompletionPromptReady(eventData) {
if (!eventData || !Array.isArray(eventData.chat)) {
return;
}
if (eventData.dryRun) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
eventData.chat = injectContextIntoChatPrompt(eventData.chat);
// DON'T clear pendingContextMap here - let it persist for other generations
// (e.g., prewarm extensions). It will be cleared on GENERATION_ENDED.
}
/**
* Event handler for generation start.
* Manages tracker data commitment and prompt injection based on generation mode.
@@ -55,8 +552,8 @@ export async function onGenerationStarted(type, data, dryRun) {
// console.log('[RPG Companion] Committed Prompt:', committedTrackerData);
// Skip tracker injection for image generation requests
if (data?.quietImage) {
// console.log('[RPG Companion] Detected image generation (quietImage=true), skipping tracker injection');
if (data?.quietImage || data?.quiet_image || data?.isImageGeneration) {
// console.log('[RPG Companion] Detected image generation, skipping tracker injection');
return;
}
@@ -99,7 +596,6 @@ export async function onGenerationStarted(type, data, dryRun) {
await restoreCheckpointOnLoad();
const currentChatLength = chat ? chat.length : 0;
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') {
@@ -261,7 +757,7 @@ export async function onGenerationStarted(type, data, dryRun) {
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n${htmlPromptText}`;
const htmlPrompt = `\n- ${htmlPromptText}\n`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for together mode');
@@ -274,7 +770,7 @@ export async function onGenerationStarted(type, data, dryRun) {
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}`;
const dialogueColoringPrompt = `\n- ${dialogueColoringPromptText}\n`;
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');
@@ -283,11 +779,24 @@ export async function onGenerationStarted(type, data, dryRun) {
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Deception System prompt separately at depth 0 if enabled
if (extensionSettings.enableDeceptionSystem && !shouldSuppress) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
const deceptionPrompt = `\n- ${deceptionPromptText}\n`;
setExtensionPrompt('rpg-companion-deception', deceptionPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Deception System prompt at depth 0 for together mode');
} else {
// Clear Deception System prompt if disabled
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT;
const spotifyPrompt = `\n${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}`;
const spotifyPrompt = `\n- ${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}\n`;
setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Spotify prompt at depth 0 for together mode');
@@ -295,6 +804,20 @@ export async function onGenerationStarted(type, data, dryRun) {
// Clear Spotify prompt if disabled
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject CYOA prompt separately at depth 0 if enabled (injected last to appear last in prompt)
if (extensionSettings.enableCYOA && !shouldSuppress) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
const cyoaPrompt = `\n- ${cyoaPromptText}\n`;
setExtensionPrompt('rpg-companion-zzz-cyoa', cyoaPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected CYOA prompt at depth 0 for together mode');
} else {
// Clear CYOA prompt if disabled
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
}
} else if (extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') {
// In SEPARATE and EXTERNAL modes, inject the contextual summary for main roleplay generation
const contextSummary = generateContextualSummary();
@@ -322,7 +845,7 @@ Ensure these details naturally reflect and influence the narrative. Character be
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n${htmlPromptText}`;
const htmlPrompt = `\n- ${htmlPromptText}\n`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate/external mode');
@@ -331,11 +854,37 @@ Ensure these details naturally reflect and influence the narrative. Character be
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}\n`;
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 separate/external mode');
} else {
// Clear Dialogue Coloring prompt if disabled
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Deception System prompt separately at depth 0 if enabled
if (extensionSettings.enableDeceptionSystem && !shouldSuppress) {
// Use custom Deception prompt if set, otherwise use default
const deceptionPromptText = extensionSettings.customDeceptionPrompt || DEFAULT_DECEPTION_PROMPT;
const deceptionPrompt = `\n- ${deceptionPromptText}\n`;
setExtensionPrompt('rpg-companion-deception', deceptionPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Deception System prompt at depth 0 for separate/external mode');
} else {
// Clear Deception System prompt if disabled
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
const spotifyPromptText = extensionSettings.customSpotifyPrompt || DEFAULT_SPOTIFY_PROMPT;
const spotifyPrompt = `\n${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}`;
const spotifyPrompt = `\n- ${spotifyPromptText} ${SPOTIFY_FORMAT_INSTRUCTION}\n`;
setExtensionPrompt('rpg-companion-spotify', spotifyPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Spotify prompt at depth 0 for separate/external mode');
@@ -344,6 +893,19 @@ Ensure these details naturally reflect and influence the narrative. Character be
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject CYOA prompt separately at depth 0 if enabled (injected last to appear last in prompt)
if (extensionSettings.enableCYOA && !shouldSuppress) {
// Use custom CYOA prompt if set, otherwise use default
const cyoaPromptText = extensionSettings.customCYOAPrompt || DEFAULT_CYOA_PROMPT;
const cyoaPrompt = `\n- ${cyoaPromptText}\n`;
setExtensionPrompt('rpg-companion-zzz-cyoa', cyoaPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected CYOA prompt at depth 0 for separate/external mode');
} else {
// Clear CYOA prompt if disabled
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Clear together mode injections
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
@@ -353,6 +915,39 @@ Ensure these details naturally reflect and influence the narrative. Character be
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, 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-deception', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Set suppression state for the historical context injection
currentSuppressionState = shouldSuppress;
// Prepare historical context for injection into prompts
// This builds the context map but does NOT modify original chat messages
// The persistent event listeners will inject it into all prompts until cleared
prepareHistoricalContextInjection();
}
/**
* Initialize the history injection event listeners.
* These are persistent listeners that inject context into ALL generations
* while pendingContextMap has data. Should be called once at extension init.
*/
export function initHistoryInjectionListeners() {
// Register persistent listeners for prompt injection
// These check pendingContextMap and only inject if there's data
// Primary: BEFORE_COMBINE for text completion (more reliable - modifies message objects)
eventSource.on(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, onGenerateBeforeCombinePrompts);
// Fallback: AFTER_COMBINE for text completion (string-based injection)
eventSource.on(event_types.GENERATE_AFTER_COMBINE_PROMPTS, onGenerateAfterCombinePrompts);
// Chat completion (OpenAI, etc.)
eventSource.on(event_types.CHAT_COMPLETION_PROMPT_READY, onChatCompletionPromptReady);
console.log('[RPG Companion] History injection listeners initialized');
}
+26 -4
View File
@@ -28,6 +28,7 @@ export function buildUserStatsJSONInstruction() {
const trackerConfig = extensionSettings.trackerConfig;
const userStatsConfig = trackerConfig?.userStats;
const enabledStats = userStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || [];
const displayMode = userStatsConfig?.statsDisplayMode || 'percentage';
let instruction = '{\n';
instruction += ' "stats": [\n';
@@ -36,7 +37,12 @@ export function buildUserStatsJSONInstruction() {
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`;
if (displayMode === 'number') {
const maxValue = stat.maxValue || 100;
instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma} // 0 to ${maxValue}\n`;
} else {
instruction += ` {"id": "${stat.id}", "name": "${stat.name}", "value": X}${comma} // 0 to 100 (percentage)\n`;
}
}
instruction += ' ],\n';
@@ -45,9 +51,24 @@ export function buildUserStatsJSONInstruction() {
if (userStatsConfig?.statusSection?.enabled) {
instruction += ' "status": {\n';
if (userStatsConfig.statusSection.showMoodEmoji) {
instruction += ' "mood": "Mood Emoji",\n';
instruction += ' "mood": "Mood Emoji"';
}
// Add all custom status fields
const customFields = userStatsConfig.statusSection.customFields || [];
if (customFields.length > 0) {
for (let i = 0; i < customFields.length; i++) {
const fieldName = customFields[i].toLowerCase();
const fieldKey = toSnakeCase(fieldName);
const comma = (i === customFields.length - 1 && !userStatsConfig.statusSection.showMoodEmoji) ? '' : (userStatsConfig.statusSection.showMoodEmoji || i < customFields.length - 1 ? ',\n' : '\n');
if (i === 0 && userStatsConfig.statusSection.showMoodEmoji) {
instruction += ',\n';
}
instruction += ` "${fieldKey}": "[${fieldName}1, ${fieldName}2]"${comma}`;
}
}
if (!userStatsConfig.statusSection.showMoodEmoji && customFields.length > 0) {
instruction += '\n';
}
instruction += ' "conditions": "[Condition1, Condition2]"\n';
instruction += ' },\n';
}
@@ -105,7 +126,8 @@ export function buildInfoBoxJSONInstruction() {
let hasFields = false;
if (widgets.date?.enabled) {
instruction += ' "date": {"value": "Weekday, Month, Year"}';
const dateFormat = widgets.date.format || 'Weekday, Month, Year';
instruction += ` "date": {"value": "${dateFormat}"}`;
hasFields = true;
}
+44 -10
View File
@@ -198,7 +198,9 @@ export function parseResponse(responseText) {
if (depth === 0) {
// Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim();
extractedObjects.push(jsonContent);
if (jsonContent) {
extractedObjects.push(jsonContent);
}
i = j;
} else {
i++;
@@ -307,6 +309,9 @@ export function parseResponse(responseText) {
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
if (!jsonContent) continue;
// console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent);
@@ -363,6 +368,9 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) {
const jsonContent = match[1].trim();
if (!jsonContent) continue;
const parsed = repairJSON(jsonContent);
if (parsed) {
@@ -524,7 +532,7 @@ export function parseUserStats(statsText) {
// Check if this is v3 JSON format - try to parse it first
let statsData = null;
const trimmed = statsText.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
statsData = repairJSON(statsText);
if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
@@ -547,9 +555,15 @@ export function parseUserStats(statsText) {
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 all custom status fields
const trackerConfig = extensionSettings.trackerConfig;
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
if (statsData.status[fieldKey]) {
extensionSettings.userStats[fieldKey] = statsData.status[fieldKey];
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, statsData.status[fieldKey]);
}
}
}
@@ -679,6 +693,7 @@ export function parseUserStats(statsText) {
const statusConfig = trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
let moodMatch = null;
const customFields = statusConfig.customFields || [];
// Try Status: format
const statusMatch = statsText.match(/Status:\s*(.+)/i);
@@ -691,14 +706,30 @@ export function parseUserStats(statsText) {
if (emoji) {
extensionSettings.userStats.mood = emoji;
// Remaining text contains custom status fields
if (text) {
extensionSettings.userStats.conditions = text;
if (text && customFields.length > 0) {
// For first custom field, use the remaining text
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = text;
}
moodMatch = true;
}
} else {
// No mood emoji, whole status is conditions
extensionSettings.userStats.conditions = statusContent;
// No mood emoji, whole status goes to first custom field
if (customFields.length > 0) {
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = statusContent;
}
moodMatch = true;
}
}
// Try to extract individual custom status fields by name
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldRegex = new RegExp(`${fieldName}:\\s*(.+?)(?:,|$)`, 'i');
const fieldMatch = statsText.match(fieldRegex);
if (fieldMatch) {
extensionSettings.userStats[fieldKey] = fieldMatch[1].trim();
moodMatch = true;
}
}
@@ -706,7 +737,10 @@ export function parseUserStats(statsText) {
debugLog('[RPG Parser] Status match:', {
found: !!moodMatch,
mood: extensionSettings.userStats.mood,
conditions: extensionSettings.userStats.conditions
customFields: customFields.map(f => ({
name: f,
value: extensionSettings.userStats[f.toLowerCase()]
}))
});
}
+424 -39
View File
@@ -28,6 +28,16 @@ export const DEFAULT_HTML_PROMPT = `If appropriate, include inline HTML, CSS, an
*/
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 Deception System prompt text
*/
export const DEFAULT_DECEPTION_PROMPT = `When a character is lying or deceiving, you should follow up that line with the <lie> tag, containing a brief description of the truth and the lie's reason, using the template below (replace placeholders in quotation marks). This will be hidden from the user's view, but not to you, making it useful for future consequences: <lie character="name" type="lying/deceiving/omitting" truth="truth" reason="reason"/>.`;
/**
* Default CYOA prompt text
*/
export const DEFAULT_CYOA_PROMPT = `Since this is a "Choose Your Own Adventure" type of game, you must finish your response by creating a numbered list of 5 different possible action or dialogue options (depending on the scene) for the user to choose from. Make sure they all fit their persona well. They will respond with their choice on how to progress.`;
/**
* Default Spotify music prompt text (customizable by users)
*/
@@ -229,7 +239,6 @@ function buildAttributesString() {
*/
export function generateTrackerExample() {
let example = '';
const useXmlTags = extensionSettings.saveTrackerHistory;
// Use COMMITTED data for generation context, not displayed data
// Apply locks before sending to AI (for JSON format only)
@@ -310,26 +319,18 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
// Only add tracker instructions if at least one tracker is enabled
if (hasAnyTrackers) {
// Determine format based on saveTrackerHistory setting
const useXmlTags = extensionSettings.saveTrackerHistory;
const openTag = useXmlTags ? '<trackers>\n' : '';
const closeTag = useXmlTags ? '\n</trackers>' : '';
const codeBlockMarker = '';
const endCodeBlockMarker = '';
// Universal instruction header
if (useXmlTags) {
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. `;
} else {
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 JSON format shown below as a single unified JSON object containing all enabled tracker fields. ';
// Append custom instruction portion if available
const customPrompt = extensionSettings.customTrackerInstructionsPrompt;
if (customPrompt) {
instructions += customPrompt.replace(/{userName}/g, userName);
} 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. 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 "😊". DO NOT include ${userName} in the characters section, only NPCs. `;
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.`;
}
@@ -391,21 +392,30 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
// Include attributes based on settings (only if includeAttributes is true)
if (includeAttributes) {
const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes;
const shouldSendAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll;
const showRPGAttributes = trackerConfig?.userStats?.showRPGAttributes !== false;
const shouldSendAttributes = alwaysSendAttributes && showRPGAttributes;
if (shouldSendAttributes) {
const attributesString = buildAttributesString();
instructions += `${userName}'s attributes: ${attributesString}\n`;
// Add dice roll context if there was one
if (extensionSettings.lastDiceRoll) {
const roll = extensionSettings.lastDiceRoll;
instructions += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeeded or failed the action they attempted.\n\n`;
} else {
instructions += `\n`;
}
}
}
// Add dice roll context if there was one (independent of attributes)
if (extensionSettings.lastDiceRoll) {
const roll = extensionSettings.lastDiceRoll;
const showRPGAttributes = trackerConfig?.userStats?.showRPGAttributes !== false;
const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes;
const hasAttributes = includeAttributes && (alwaysSendAttributes && showRPGAttributes);
if (hasAttributes) {
instructions += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeeded or failed the action they attempted.\n\n`;
} else {
instructions += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Decide whether they succeeded or failed the action they attempted.\n\n`;
}
} else if (includeAttributes && trackerConfig?.userStats?.alwaysSendAttributes && trackerConfig?.userStats?.showRPGAttributes !== false) {
instructions += `\n`;
}
}
// Append HTML prompt if enabled AND includeHtmlPrompt is true
@@ -475,11 +485,22 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
// Handle common object formats
if (field && typeof field === 'object') {
// Status object: {mood, conditions}
if ('mood' in field && 'conditions' in field) {
// Status object: {mood, [customFields...]}
if ('mood' in field) {
const statusParts = [];
const mood = getValue(field.mood);
const conditions = getValue(field.conditions);
return `${mood} - ${conditions}`;
if (mood) statusParts.push(mood);
// Add all other status fields (custom fields)
for (const [key, value] of Object.entries(field)) {
if (key !== 'mood') {
const fieldValue = getValue(value);
if (fieldValue && fieldValue !== 'None') {
statusParts.push(fieldValue);
}
}
}
return statusParts.join(' - ');
}
// Skill/item/quest objects: {name}, {title}, {name, quantity}
@@ -728,6 +749,244 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
}
}
/**
* Formats historical tracker data from a message's rpg_companion_swipes data.
* Only includes tracker fields that have persistInHistory enabled in trackerConfig,
* unless useAllEnabled is true, in which case it includes all enabled fields.
* Uses the same formatting as formatTrackerDataForContext but filtered by persistence settings.
*
* @param {Object} trackerData - The tracker data from message.extra.rpg_companion_swipes[swipeId]
* @param {Object} trackerConfig - The tracker configuration from extensionSettings.trackerConfig
* @param {string} userName - The user's name for personalization
* @param {boolean} [useAllEnabled=false] - If true, include all enabled fields instead of only persistInHistory fields
* @returns {string} Formatted historical context or empty string if nothing to include
*/
export function formatHistoricalTrackerData(trackerData, trackerConfig, userName, useAllEnabled = false) {
if (!trackerData || !trackerConfig) {
return '';
}
// Helper to check if a field should be included
const shouldInclude = (config) => {
if (useAllEnabled) {
return config?.enabled !== false; // Include if enabled (default true for most fields)
}
return config?.persistInHistory === true;
};
// Helper to check if a stat/attribute should be included
const shouldIncludeStat = (configStat) => {
if (useAllEnabled) {
return configStat?.enabled !== false;
}
return configStat?.persistInHistory === true;
};
let formatted = '';
// Helper to safely get values
const getValue = (field) => {
if (field === null || field === undefined) return '';
if (field && typeof field === 'object' && !Array.isArray(field) && 'value' in field) {
return getValue(field.value);
}
if (typeof field !== 'object') {
return String(field);
}
if (Array.isArray(field)) {
return field.map(item => getValue(item)).filter(Boolean).join(', ');
}
if (field && typeof field === 'object') {
if ('start' in field && 'end' in field) {
return `${getValue(field.start)} - ${getValue(field.end)}`;
}
if ('emoji' in field && 'forecast' in field) {
return `${getValue(field.emoji)} ${getValue(field.forecast)}`;
}
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);
}
}
return '';
};
try {
// Process userStats if present and has persistence-enabled fields
if (trackerData.userStats) {
const userStatsConfig = trackerConfig.userStats;
const userStatsData = typeof trackerData.userStats === 'string'
? JSON.parse(trackerData.userStats)
: trackerData.userStats;
let statsFormatted = '';
// Custom stats with persistInHistory enabled (or enabled if useAllEnabled)
if (userStatsData.stats && Array.isArray(userStatsData.stats) && userStatsConfig.customStats) {
for (const stat of userStatsData.stats) {
const configStat = userStatsConfig.customStats.find(s => s.id === stat.id);
if (shouldIncludeStat(configStat) && stat.value !== undefined) {
const statName = stat.name || configStat.name || stat.id;
statsFormatted += `${statName}: ${stat.value}, `;
}
}
}
// Status section
if (shouldInclude(userStatsConfig.statusSection) && userStatsData.status) {
const mood = getValue(userStatsData.status.mood || userStatsData.status);
if (mood && userStatsConfig.statusSection.showMoodEmoji) statsFormatted += `Mood: ${mood}, `;
// Add all custom status fields
const customFields = userStatsConfig.statusSection.customFields || [];
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldValue = getValue(userStatsData.status[fieldKey]);
if (fieldValue && fieldValue !== 'None') {
statsFormatted += `${fieldName}: ${fieldValue}, `;
}
}
}
// Skills section
if (shouldInclude(userStatsConfig.skillsSection) && userStatsData.skills) {
const skillsList = Array.isArray(userStatsData.skills)
? userStatsData.skills.map(s => getValue(s)).filter(s => s).join(', ')
: getValue(userStatsData.skills);
if (skillsList) statsFormatted += `Skills: ${skillsList}, `;
}
// Inventory
const shouldIncludeInventory = useAllEnabled || userStatsConfig.inventoryPersistInHistory;
if (shouldIncludeInventory && userStatsData.inventory) {
const inv = userStatsData.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) statsFormatted += `On Person: ${items.join(', ')}, `;
}
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) statsFormatted += `Clothing: ${items.join(', ')}, `;
}
}
// Quests
const shouldIncludeQuests = useAllEnabled || userStatsConfig.questsPersistInHistory;
if (shouldIncludeQuests && userStatsData.quests) {
const quests = userStatsData.quests;
if (quests.main) {
const mainQuest = getValue(quests.main);
if (mainQuest && mainQuest !== 'None') statsFormatted += `Quest: ${mainQuest}, `;
}
}
if (statsFormatted) {
formatted += `${userName}: ${statsFormatted.slice(0, -2)}\n`;
}
}
// Process infoBox if present and has persistence-enabled widgets
if (trackerData.infoBox) {
const infoBoxConfig = trackerConfig.infoBox;
const infoBoxData = typeof trackerData.infoBox === 'string'
? JSON.parse(trackerData.infoBox)
: trackerData.infoBox;
let infoFormatted = '';
// Date
if (shouldInclude(infoBoxConfig.widgets.date) && infoBoxData.date) {
const date = getValue(infoBoxData.date);
if (date) infoFormatted += `Date: ${date}, `;
}
// Time
if (shouldInclude(infoBoxConfig.widgets.time) && infoBoxData.time) {
const time = getValue(infoBoxData.time);
if (time) infoFormatted += `Time: ${time}, `;
}
// Weather
if (shouldInclude(infoBoxConfig.widgets.weather) && infoBoxData.weather) {
const weather = getValue(infoBoxData.weather);
if (weather) infoFormatted += `Weather: ${weather}, `;
}
// Temperature
if (shouldInclude(infoBoxConfig.widgets.temperature) && infoBoxData.temperature) {
const temp = getValue(infoBoxData.temperature);
if (temp) infoFormatted += `Temp: ${temp}, `;
}
// Location
if (shouldInclude(infoBoxConfig.widgets.location) && infoBoxData.location) {
const location = getValue(infoBoxData.location);
if (location) infoFormatted += `Location: ${location}, `;
}
// Recent Events
if (shouldInclude(infoBoxConfig.widgets.recentEvents) && infoBoxData.recentEvents) {
const events = getValue(infoBoxData.recentEvents);
if (events) infoFormatted += `Events: ${events}, `;
}
if (infoFormatted) {
formatted += infoFormatted.slice(0, -2) + '\n';
}
}
// Process characterThoughts if present and has persistence-enabled fields
if (trackerData.characterThoughts) {
const charsConfig = trackerConfig.presentCharacters;
const charsData = typeof trackerData.characterThoughts === 'string'
? JSON.parse(trackerData.characterThoughts)
: trackerData.characterThoughts;
// Characters can be an array or wrapped in an object
const characters = Array.isArray(charsData) ? charsData : (charsData.characters || []);
for (const char of characters) {
if (!char || !char.name) continue;
let charFormatted = '';
// Custom fields (appearance, demeanor, etc.)
if (char.details && typeof char.details === 'object') {
for (const field of charsConfig.customFields) {
if (shouldIncludeStat(field) && char.details[field.id]) {
const value = getValue(char.details[field.id]);
if (value) charFormatted += `${field.name}: ${value}, `;
}
}
}
// Thoughts
if (shouldInclude(charsConfig.thoughts) && char.thoughts) {
const thoughts = typeof char.thoughts === 'object' && char.thoughts.content
? getValue(char.thoughts.content)
: getValue(char.thoughts);
if (thoughts) charFormatted += `Thinking: ${thoughts}, `;
}
if (charFormatted) {
formatted += `${getValue(char.name)}: ${charFormatted.slice(0, -2)}\n`;
}
}
}
return formatted.trim();
} catch (e) {
console.warn('[RPG Companion] Failed to format historical tracker data:', e);
return '';
}
}
/**
* Generates a formatted contextual summary for SEPARATE mode injection.
* Includes the full tracker data in original format (without code fences and separators).
@@ -779,19 +1038,25 @@ export function generateContextualSummary() {
// Include attributes based on settings
const alwaysSendAttributes = trackerConfig?.userStats?.alwaysSendAttributes;
const shouldSendAttributes = alwaysSendAttributes || extensionSettings.lastDiceRoll;
const showRPGAttributes = trackerConfig?.userStats?.showRPGAttributes !== false;
const shouldSendAttributes = alwaysSendAttributes && showRPGAttributes;
if (shouldSendAttributes) {
const attributesString = buildAttributesString();
summary += `${userName}'s attributes: ${attributesString}\n`;
}
// Add dice roll context if there was one
if (extensionSettings.lastDiceRoll) {
const roll = extensionSettings.lastDiceRoll;
// Add dice roll context if there was one (independent of attributes)
if (extensionSettings.lastDiceRoll) {
const roll = extensionSettings.lastDiceRoll;
if (shouldSendAttributes) {
summary += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeeded or failed the action they attempted.\n\n`;
} else {
summary += `\n`;
summary += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Decide whether they succeeded or failed the action they attempted.\n\n`;
}
} else if (shouldSendAttributes) {
summary += `\n`;
}
return summary.trim();
@@ -844,16 +1109,34 @@ export function generateRPGPromptText() {
}
}
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
// Include Present Characters data if it exists, regardless of current showCharacterThoughts setting
// This ensures existing character data is preserved in context even if the setting is toggled off
if (committedTrackerData.characterThoughts) {
try {
// Try to parse as JSON - apply locks before adding to previous
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
const parsed = JSON.parse(lockedData);
unifiedPrevious.characters = parsed;
} catch {
let parsed;
// Check if it's already a JavaScript object/array (not a JSON string)
if (typeof committedTrackerData.characterThoughts === 'object') {
// Already parsed - apply locks and use directly
parsed = applyLocks(committedTrackerData.characterThoughts, 'characters');
} else {
// It's a JSON string - apply locks and parse
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
parsed = JSON.parse(lockedData);
}
// Only include if there's actual character data (non-empty array or object with content)
if (parsed && ((Array.isArray(parsed) && parsed.length > 0) ||
(parsed.characters && Array.isArray(parsed.characters) && parsed.characters.length > 0))) {
unifiedPrevious.characters = parsed;
}
} catch (e) {
// console.warn('[RPG Companion] Failed to process characters for previous section:', e);
// Old text format - show it separately for backward compat
if (!unifiedPrevious.userStats && !unifiedPrevious.infoBox) {
promptText += `${committedTrackerData.characterThoughts}\n`;
const charText = typeof committedTrackerData.characterThoughts === 'string'
? committedTrackerData.characterThoughts
: JSON.stringify(committedTrackerData.characterThoughts, null, 2);
promptText += `${charText}\n`;
}
}
}
@@ -883,6 +1166,8 @@ export function generateRPGPromptText() {
export async function generateSeparateUpdatePrompt() {
const depth = extensionSettings.updateDepth;
const userName = getContext().name1;
const trackerConfig = extensionSettings.trackerConfig;
const historyPersistence = extensionSettings.historyPersistence;
const messages = [];
@@ -899,6 +1184,7 @@ export async function generateSeparateUpdatePrompt() {
systemMessage += `Here is the description of the protagonist for reference:\n`;
systemMessage += `<protagonist>\n{{persona}}\n</protagonist>\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>`;
messages.push({
@@ -907,13 +1193,112 @@ export async function generateSeparateUpdatePrompt() {
});
// /hide command automatically handles checkpoint filtering
// Add chat history as separate user/assistant messages
// Add chat history as separate user/assistant messages with per-message historical context
const recentMessages = chat.slice(-depth);
const startIndex = chat.length - depth;
const position = historyPersistence?.injectionPosition || 'assistant_message_end';
// Build a map of which messages should get context based on position setting
// Key: message index in recentMessages, Value: context string
const contextInjectionMap = new Map();
if (historyPersistence?.enabled) {
// Find the last assistant message index (in recentMessages)
let lastAssistantIdx = -1;
for (let i = recentMessages.length - 1; i >= 0; i--) {
if (!recentMessages[i].is_user && !recentMessages[i].is_system) {
lastAssistantIdx = i;
break;
}
}
// Iterate through assistant messages to find tracker data
for (let i = 0; i < recentMessages.length; i++) {
const message = recentMessages[i];
// Skip user and system messages - only assistant messages have tracker data
if (message.is_user || message.is_system) {
continue;
}
// Skip the last assistant message - it gets current context elsewhere
if (i === lastAssistantIdx) {
continue;
}
// Get the rpg_companion_swipes data for current swipe
// Data can be in two places:
// 1. message.extra.rpg_companion_swipes (current session, before save)
// 2. message.swipe_info[swipeId].extra.rpg_companion_swipes (loaded from file)
const currentSwipeId = message.swipe_id || 0;
let swipeData = message.extra?.rpg_companion_swipes;
// If not in message.extra, check swipe_info
if (!swipeData && message.swipe_info && message.swipe_info[currentSwipeId]) {
swipeData = message.swipe_info[currentSwipeId].extra?.rpg_companion_swipes;
}
if (!swipeData) {
continue;
}
const trackerData = swipeData[currentSwipeId];
if (!trackerData) {
continue;
}
// For Refresh RPG Info, use sendAllEnabledOnRefresh setting
// When true, include all enabled stats from preset instead of only persistInHistory stats
const useAllEnabled = historyPersistence.sendAllEnabledOnRefresh === true;
const formattedContext = formatHistoricalTrackerData(trackerData, trackerConfig, userName, useAllEnabled);
if (!formattedContext) {
continue;
}
const preamble = historyPersistence.contextPreamble || 'Context for that moment:';
const wrappedContext = `\n${preamble}\n${formattedContext}`;
// Determine target message based on position
let targetIdx = i;
if (position === 'user_message_end') {
// Find the preceding user message before this assistant message
// This is the user message that prompted this assistant response
for (let j = i - 1; j >= 0; j--) {
if (recentMessages[j].is_user && !recentMessages[j].is_system) {
targetIdx = j;
break;
}
}
// If no user message found before, skip
if (targetIdx === i) {
continue;
}
}
// For assistant_message_end: inject into the assistant message itself
// Append to existing or create new entry
if (contextInjectionMap.has(targetIdx)) {
contextInjectionMap.set(targetIdx, contextInjectionMap.get(targetIdx) + wrappedContext);
} else {
contextInjectionMap.set(targetIdx, wrappedContext);
}
}
}
// Now build the messages array with injected context
for (let i = 0; i < recentMessages.length; i++) {
const message = recentMessages[i];
let content = message.mes;
// Add historical context if this message is a target
if (contextInjectionMap.has(i)) {
content += contextInjectionMap.get(i);
}
for (const message of recentMessages) {
messages.push({
role: message.is_user ? 'user' : 'assistant',
content: message.mes
content: content
});
}
+271 -35
View File
@@ -13,14 +13,16 @@ import {
committedTrackerData,
lastActionWasSwipe,
isPlotProgression,
isAwaitingNewMessage,
setLastActionWasSwipe,
setIsPlotProgression,
setIsGenerating,
setIsAwaitingNewMessage,
updateLastGeneratedData,
updateCommittedTrackerData,
$musicPlayerContainer
} from '../../core/state.js';
import { saveChatData, loadChatData } from '../../core/persistence.js';
import { saveChatData, loadChatData, autoSwitchPresetForEntity } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
// Generation & Parsing
@@ -28,7 +30,7 @@ import { parseResponse, parseUserStats } from '../generation/parser.js';
import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js';
import { updateRPGData } from '../generation/apiClient.js';
import { removeLocks } from '../generation/lockManager.js';
import { onGenerationStarted } from '../generation/injector.js';
import { onGenerationStarted, initHistoryInjectionListeners } from '../generation/injector.js';
// Rendering
import { renderUserStats } from '../rendering/userStats.js';
@@ -41,6 +43,10 @@ import { renderMusicPlayer } from '../rendering/musicPlayer.js';
// Utils
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
// UI
import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js';
import { updateStripWidgets } from '../ui/desktop.js';
// Chapter checkpoint
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
@@ -105,6 +111,14 @@ export function onMessageSent() {
// console.log('[RPG Companion] 🟢 EVENT: onMessageSent (after placeholder check)');
// console.log('[RPG Companion] 🟢 NOTE: lastActionWasSwipe will be reset in onMessageReceived after generation completes');
// Set flag to indicate we're expecting a new message from generation
// This allows auto-update to distinguish between new generations and loading chat history
setIsAwaitingNewMessage(true);
// Note: FAB spinning is NOT shown for together mode since no extra API request is made
// The RPG data comes embedded in the main response
// FAB spinning is handled by apiClient.js for separate/external modes when updateRPGData() is called
// For separate mode with auto-update disabled, commit displayed tracker
if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) {
if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) {
@@ -190,20 +204,16 @@ export async function onMessageReceived(data) {
// Remove the tracker code blocks from the visible message
let cleanedMessage = responseText;
// Only remove trackers if saveTrackerHistory is disabled
// When enabled, trackers are in <trackers> XML tags which SillyTavern auto-hides
if (!extensionSettings.saveTrackerHistory) {
// Note: JSON code blocks are hidden from display by regex script (but preserved in message data)
// Note: JSON code blocks are hidden from display by regex script (but preserved in message data)
// Remove old text format code blocks (legacy support)
cleanedMessage = cleanedMessage.replace(/```[^`]*?Stats\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Info Box\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Present Characters\s*\n\s*---[^`]*?```\s*/gi, '');
// Remove any stray "---" dividers that might appear after the code blocks
cleanedMessage = cleanedMessage.replace(/^\s*---\s*$/gm, '');
// Clean up multiple consecutive newlines
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, '\n\n');
}
// Remove old text format code blocks (legacy support)
cleanedMessage = cleanedMessage.replace(/```[^`]*?Stats\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Info Box\s*\n\s*---[^`]*?```\s*/gi, '');
cleanedMessage = cleanedMessage.replace(/```[^`]*?Present Characters\s*\n\s*---[^`]*?```\s*/gi, '');
// Remove any stray "---" dividers that might appear after the code blocks
cleanedMessage = cleanedMessage.replace(/^\s*---\s*$/gm, '');
// Clean up multiple consecutive newlines
cleanedMessage = cleanedMessage.replace(/\n{3,}/g, '\n\n');
// Note: <trackers> XML tags are automatically hidden by SillyTavern
// Note: <Song - Artist/> tags are also automatically hidden by SillyTavern
@@ -223,6 +233,10 @@ export async function onMessageReceived(data) {
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with newly parsed data
updateFabWidgets();
updateStripWidgets();
// Then update the DOM to reflect the cleaned message
// Using updateMessageBlock to perform macro substitutions + regex formatting
const messageId = chat.length - 1;
@@ -250,13 +264,21 @@ export async function onMessageReceived(data) {
}
// Trigger auto-update if enabled (for both separate and external modes)
if (extensionSettings.autoUpdate) {
// Only trigger if this is a newly generated message, not loading chat history
if (extensionSettings.autoUpdate && isAwaitingNewMessage) {
setTimeout(async () => {
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
// Update FAB widgets and strip widgets after separate/external mode update completes
setFabLoadingState(false);
updateFabWidgets();
updateStripWidgets();
}, 500);
}
}
// Reset the awaiting flag after processing the message
setIsAwaitingNewMessage(false);
// Reset the swipe flag after generation completes
// This ensures that if the user swiped → auto-reply generated → flag is now cleared
// so the next user message will be treated as a new message (not a swipe)
@@ -272,6 +294,11 @@ export async function onMessageReceived(data) {
// console.log('[RPG Companion] Plot progression generation completed');
}
// Stop FAB loading state and update widgets
setFabLoadingState(false);
updateFabWidgets();
updateStripWidgets();
// Re-apply checkpoint in case SillyTavern unhid messages during generation
await restoreCheckpointOnLoad();
}
@@ -287,6 +314,12 @@ export function onCharacterChanged() {
$(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel');
// Auto-switch to the preset associated with this character/group (if any)
const presetSwitched = autoSwitchPresetForEntity();
// if (presetSwitched) {
// console.log('[RPG Companion] Auto-switched preset for character');
// }
// Load chat-specific data when switching chats
loadChatData();
@@ -303,6 +336,10 @@ export function onCharacterChanged() {
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with loaded data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
@@ -322,7 +359,8 @@ export function onMessageSwiped(messageIndex) {
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped at index:', messageIndex);
// Get the message that was swiped
const message = chat[messageIndex];
const currentChat = getContext().chat;
const message = currentChat[messageIndex];
if (!message || message.is_user) {
// console.log('[RPG Companion] 🔵 Ignoring swipe - message is user or undefined');
return;
@@ -340,33 +378,81 @@ export function onMessageSwiped(messageIndex) {
if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation
setLastActionWasSwipe(true);
setIsAwaitingNewMessage(true);
// console.log('[RPG Companion] 🔵 NEW swipe detected - Set lastActionWasSwipe = true');
// CRITICAL: For new swipes, commit data from the PREVIOUS assistant message
// This ensures the LLM gets context from BEFORE the message being regenerated,
// not the message itself (which would cause time/story to advance incorrectly)
for (let i = messageIndex - 1; i >= 0; i--) {
const prevMessage = currentChat[i];
if (!prevMessage.is_user && prevMessage.extra?.rpg_companion_swipes) {
const prevSwipeId = prevMessage.swipe_id || 0;
const prevSwipeData = prevMessage.extra.rpg_companion_swipes[prevSwipeId];
if (prevSwipeData) {
// console.log('[RPG Companion] 🔵 Committing tracker data from PREVIOUS message at index', i);
committedTrackerData.userStats = prevSwipeData.userStats || null;
committedTrackerData.infoBox = prevSwipeData.infoBox || null;
committedTrackerData.characterThoughts = prevSwipeData.characterThoughts || null;
} else {
// Previous message has no swipe data - clear committed data
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
break;
}
// If we hit index 0 without finding a previous assistant message, clear committed data
if (i === 0) {
// console.log('[RPG Companion] 🔵 No previous assistant message found - clearing committed data');
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
}
// Edge case: if messageIndex is 0 (first message being swiped), clear committed data
if (messageIndex === 0) {
// console.log('[RPG Companion] 🔵 Swiping first message - clearing committed data');
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
// For new swipes, also update lastGeneratedData to match committed data
// This ensures the UI shows the "before" state while waiting for the new response
lastGeneratedData.userStats = committedTrackerData.userStats;
lastGeneratedData.infoBox = committedTrackerData.infoBox;
lastGeneratedData.characterThoughts = committedTrackerData.characterThoughts;
// Parse user stats for display if available
if (committedTrackerData.userStats) {
parseUserStats(committedTrackerData.userStats);
}
} else {
// This is navigating to an EXISTING swipe - don't change the flag
// console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
}
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load RPG data for this existing swipe for DISPLAY purposes
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Load RPG data for this swipe
// lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION
// 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]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Load swipe data into lastGeneratedData for display
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Parse user stats if available
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
// Parse user stats if available
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
} else {
// console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
}
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
} else {
// console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
}
// Re-render the panels
@@ -381,6 +467,148 @@ export function onMessageSwiped(messageIndex) {
updateChatThoughts();
}
/**
* Event handler for when a message is deleted.
* Restores RPG state from the last assistant message with RPG data,
* or clears state if no messages remain.
*/
export function onMessageDeleted(messageIndex) {
if (!extensionSettings.enabled) {
return;
}
// console.log('[RPG Companion] 🗑️ EVENT: onMessageDeleted at index:', messageIndex);
const context = getContext();
const currentChat = context.chat;
// If chat is empty, clear all RPG state
if (!currentChat || currentChat.length === 0) {
// console.log('[RPG Companion] 🗑️ Chat is empty - clearing RPG state');
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
// Clear parsed stats from extensionSettings
if (extensionSettings.userStats) {
extensionSettings.userStats = null;
}
// Re-render empty panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays (removes any remaining)
updateChatThoughts();
// Save the cleared state
saveChatData();
return;
}
// Find the last assistant message with RPG data
for (let i = currentChat.length - 1; i >= 0; i--) {
const message = currentChat[i];
if (!message.is_user && message.extra?.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
const swipeData = message.extra.rpg_companion_swipes[swipeId];
if (swipeData) {
// Check if this is the same data we already have displayed
const sameUserStats = lastGeneratedData.userStats === swipeData.userStats;
const sameInfoBox = lastGeneratedData.infoBox === swipeData.infoBox;
const sameThoughts = lastGeneratedData.characterThoughts === swipeData.characterThoughts;
if (sameUserStats && sameInfoBox && sameThoughts) {
// console.log('[RPG Companion] 🗑️ RPG state already matches last message - no restore needed');
return;
}
// console.log('[RPG Companion] 🗑️ Restoring RPG state from message index', i, 'swipe', swipeId);
// Restore state from this message
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Also update committed data so next generation uses correct context
committedTrackerData.userStats = swipeData.userStats || null;
committedTrackerData.infoBox = swipeData.infoBox || null;
committedTrackerData.characterThoughts = swipeData.characterThoughts || null;
// Parse user stats if available
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
// Re-render panels with restored data
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Save the restored state
saveChatData();
return;
}
}
}
// No assistant message with RPG data found - clear state
// console.log('[RPG Companion] 🗑️ No assistant message with RPG data found - clearing state');
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
// Clear parsed stats
if (extensionSettings.userStats) {
extensionSettings.userStats = null;
}
// Re-render empty panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Save the cleared state
saveChatData();
}
/**
* Update the persona avatar image when user switches personas
*/
@@ -443,3 +671,11 @@ export async function onGenerationEnded() {
// Re-apply checkpoint if one exists
await restoreCheckpointOnLoad();
}
/**
* Initialize history injection event listeners.
* Should be called once during extension initialization.
*/
export function initHistoryInjection() {
initHistoryInjectionListeners();
}
+14 -8
View File
@@ -14,6 +14,7 @@ import { saveChatData } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Helper to generate lock icon HTML if setting is enabled
@@ -435,14 +436,12 @@ export function renderInfoBox() {
// Time widget - show if 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 timeField = hasTimeEnd ? 'timeEnd' : 'timeStart';
// Get both start and end times
const timeStartDisplay = data.timeStart || '12:00';
const timeEndDisplay = data.timeEnd || data.timeStart || '12:00';
// Parse time for clock hands
const timeMatch = timeDisplay.match(/(\d+):(\d+)/);
// Parse end time for clock hands (use end time for visual display)
const timeMatch = timeEndDisplay.match(/(\d+):(\d+)/);
let hourAngle = 0;
let minuteAngle = 0;
if (timeMatch) {
@@ -464,7 +463,11 @@ export function renderInfoBox() {
<div class="rpg-clock-center"></div>
</div>
</div>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="${timeField}" title="Click to edit">${timeDisplay}</div>
<div class="rpg-time-range">
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="Click to edit start time">${timeStartDisplay}</div>
<span class="rpg-time-separator"></span>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeEnd" title="Click to edit end time">${timeEndDisplay}</div>
</div>
</div>
`);
}
@@ -615,6 +618,9 @@ export function renderInfoBox() {
} else {
updateInfoBoxField(field, value);
}
// Update FAB widgets to reflect changes
updateFabWidgets();
});
// Update location size on input as well (real-time)
+39 -2
View File
@@ -3,10 +3,35 @@
* Handles UI rendering for quests system (main and optional quests)
*/
import { extensionSettings, $questsContainer } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extensionSettings, $questsContainer, committedTrackerData, lastGeneratedData } from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
/**
* Syncs the current extensionSettings.quests to committedTrackerData.userStats
* This ensures quest changes made via UI are reflected in the data sent to AI
*/
function syncQuestsToCommittedData() {
const currentData = committedTrackerData.userStats || lastGeneratedData.userStats;
if (!currentData) return;
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
// Update quests in the JSON data
jsonData.quests = extensionSettings.quests || { main: 'None', optional: [] };
const updatedJSON = JSON.stringify(jsonData, null, 2);
committedTrackerData.userStats = updatedJSON;
lastGeneratedData.userStats = updatedJSON;
}
} catch (e) {
console.warn('[RPG Quests] Failed to sync quests to committed data:', e);
}
}
}
/**
* Helper to generate lock icon HTML if setting is enabled
* @param {string} tracker - Tracker name
@@ -250,7 +275,10 @@ function attachQuestEventHandlers() {
}
extensionSettings.quests.optional.push(questTitle);
}
// Sync quest changes to committedTrackerData so AI sees the addition
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
}
});
@@ -278,7 +306,10 @@ function attachQuestEventHandlers() {
if (questTitle) {
extensionSettings.quests.main = questTitle;
// Sync quest changes to committedTrackerData so AI sees the edit
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
}
});
@@ -293,7 +324,10 @@ function attachQuestEventHandlers() {
} else {
extensionSettings.quests.optional.splice(index, 1);
}
// Sync quest changes to committedTrackerData so AI sees the removal
syncQuestsToCommittedData();
saveSettings();
saveChatData();
renderQuests();
});
@@ -306,7 +340,10 @@ function attachQuestEventHandlers() {
if (newTitle && field === 'optional' && index !== undefined) {
extensionSettings.quests.optional[index] = newTitle;
// Sync quest changes to committedTrackerData so AI sees the edit
syncQuestsToCommittedData();
saveSettings();
saveChatData();
}
});
+128 -43
View File
@@ -391,50 +391,10 @@ export function renderThoughts() {
debugLog('[RPG Thoughts] ==================== BUILDING HTML ====================');
debugLog('[RPG Thoughts] Starting HTML generation for', presentCharacters.length + ' characters');
// If no characters parsed, show a placeholder editable card
// If no characters parsed, show empty state (no placeholder)
if (presentCharacters.length === 0) {
debugLog('[RPG Thoughts] ⚠ No characters parsed - showing placeholder card');
// Get default character portrait
let defaultPortrait = FALLBACK_AVATAR_DATA_URI;
let defaultName = 'Character';
if (this_chid !== undefined && characters[this_chid]) {
if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
defaultPortrait = thumbnailUrl;
}
}
defaultName = characters[this_chid].name || 'Character';
}
html += '<div class="rpg-thoughts-content">';
html += `
<div class="rpg-character-card" data-character-name="${defaultName}">
<div class="rpg-character-avatar">
<img src="${defaultPortrait}" alt="${defaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)"></div>
</div>
<div class="rpg-character-info">
<div class="rpg-character-header">
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="emoji" title="Click to edit emoji">😊</span>
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="name" title="Click to edit name">${defaultName}</span>
</div>
`;
// Add custom fields dynamically
for (const field of enabledFields) {
const fieldId = field.name.toLowerCase().replace(/\s+/g, '-');
html += `
<div class="rpg-character-field rpg-character-${fieldId} rpg-editable" contenteditable="true" data-character="${defaultName}" data-field="${field.name}" title="Click to edit ${field.name}"></div>
`;
}
html += `
</div>
</div>
`;
html += '</div>';
debugLog('[RPG Thoughts] ⚠ No characters parsed - showing empty state');
html += '<div class="rpg-thoughts-content"></div>';
} else {
html += '<div class="rpg-thoughts-content">';
@@ -540,6 +500,7 @@ export function renderThoughts() {
<div class="rpg-character-header">
<span class="rpg-character-emoji rpg-editable" contenteditable="true" data-character="${char.name}" data-field="emoji" title="Click to edit emoji">${char.emoji}</span>
<span class="rpg-character-name rpg-editable" contenteditable="true" data-character="${char.name}" data-field="name" title="Click to edit name">${char.name}</span>
<button class="rpg-character-remove" data-character="${char.name}" title="Remove character">×</button>
</div>
`;
@@ -650,6 +611,15 @@ export function renderThoughts() {
saveSettings();
});
// Add event listener for character remove button
$thoughtsContainer.find('.rpg-character-remove').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
const characterName = $(this).data('character');
removeCharacter(characterName);
});
// Add event listener for avatar upload clicks
$thoughtsContainer.find('.rpg-avatar-upload').on('click', function(e) {
e.preventDefault();
@@ -703,6 +673,121 @@ export function renderThoughts() {
}
}
/**
* Removes a character from Present Characters data and re-renders.
*
* @param {string} characterName - Name of the character to remove
*/
export function removeCharacter(characterName) {
if (!lastGeneratedData.characterThoughts) {
return;
}
// Check if data is in JSON format
let isJSON = false;
let parsedData = null;
try {
parsedData = typeof lastGeneratedData.characterThoughts === 'string'
? JSON.parse(lastGeneratedData.characterThoughts)
: lastGeneratedData.characterThoughts;
if (Array.isArray(parsedData) || (parsedData && parsedData.characters)) {
isJSON = true;
}
} catch (e) {
// Not JSON, treat as text format
}
if (isJSON) {
// JSON format - remove character from array
let characters = Array.isArray(parsedData) ? parsedData : parsedData.characters;
characters = characters.filter(char => char.name !== characterName);
if (Array.isArray(parsedData)) {
parsedData = characters;
} else {
parsedData.characters = characters;
}
const updatedJSON = JSON.stringify(parsedData, null, 2);
lastGeneratedData.characterThoughts = updatedJSON;
committedTrackerData.characterThoughts = updatedJSON;
} else {
// Text format - remove character block
const lines = lastGeneratedData.characterThoughts.split('\n');
const dividerIndex = lines.findIndex(line => line.includes('---'));
if (dividerIndex === -1) return;
// Find the character block to remove
let startLineIndex = -1;
let endLineIndex = -1;
for (let i = dividerIndex + 1; i < lines.length; i++) {
const line = lines[i].trim();
// Check if this is the start of the character block
if (line.startsWith('Name:')) {
const nameMatch = line.match(/^Name:\s*(.+)/);
if (nameMatch && nameMatch[1].trim() === characterName) {
startLineIndex = i;
}
}
// If we found the start, look for the end
if (startLineIndex !== -1 && i > startLineIndex) {
// End of block is either another "Name:" line or end of content
if (line.startsWith('Name:') || i === lines.length - 1) {
endLineIndex = line.startsWith('Name:') ? i - 1 : i;
// Remove empty lines at the end of the block
while (endLineIndex > startLineIndex && !lines[endLineIndex].trim()) {
endLineIndex--;
}
break;
}
}
}
// Remove the character block
if (startLineIndex !== -1 && endLineIndex !== -1) {
lines.splice(startLineIndex, endLineIndex - startLineIndex + 1);
// Remove empty lines after removal to keep formatting clean
let i = startLineIndex;
while (i < lines.length && !lines[i].trim()) {
lines.splice(i, 1);
}
}
lastGeneratedData.characterThoughts = lines.join('\n');
committedTrackerData.characterThoughts = lines.join('\n');
}
// Update message swipe data
const chat = getContext().chat;
if (chat && chat.length > 0) {
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].characterThoughts = lastGeneratedData.characterThoughts;
}
}
break;
}
}
}
saveChatData();
// Re-render to show updated character list
renderThoughts();
}
/**
* Updates a specific character field in Present Characters data and re-renders.
* Works with the new multi-line format.
+65 -20
View File
@@ -20,6 +20,7 @@ import {
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js';
/**
* Builds the user stats text string using custom stat names
@@ -104,7 +105,8 @@ function updateUserStatsData() {
// 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']);
const customFields = config.statusSection?.customFields || [];
const excludeFields = new Set(['mood', ...customFields.map(f => f.toLowerCase()), 'inventory', 'skills', 'level']);
Object.entries(stats).forEach(([key, value]) => {
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
statsArray.push({
@@ -117,12 +119,17 @@ function updateUserStatsData() {
jsonData.stats = statsArray;
// Update status
// Update status - include all custom status fields
jsonData.status = {
mood: stats.mood || '😐',
conditions: stats.conditions || 'None'
mood: stats.mood || '😐'
};
// Add all custom status fields
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
jsonData.status[fieldKey] = stats[fieldKey] || 'None';
}
// Update inventory (convert to v3 format)
const convertToV3Items = (itemString) => {
if (!itemString) return [];
@@ -275,16 +282,33 @@ export function renderUserStats() {
}
html += '<div class="rpg-stats-grid">';
const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id);
const displayMode = config.statsDisplayMode || 'percentage';
for (const stat of enabledStats) {
const value = stats[stat.id] !== undefined ? stats[stat.id] : 100;
const maxValue = stat.maxValue || 100;
// Calculate percentage for bar fill
let percentage;
let displayValue;
if (displayMode === 'number') {
// In number mode, value is already the number (0 to maxValue)
percentage = maxValue > 0 ? (value / maxValue) * 100 : 100;
displayValue = `${value}/${maxValue}`;
} else {
// In percentage mode, value is 0-100
percentage = value;
displayValue = `${value}%`;
}
html += `
<div class="rpg-stat-row">
<span class="rpg-stat-label rpg-editable-stat-name" contenteditable="true" data-field="${stat.id}" title="Click to edit stat name">${stat.name}:</span>
<div class="rpg-stat-bar" style="background: ${gradient}">
<div class="rpg-stat-fill" style="width: ${100 - value}%"></div>
<div class="rpg-stat-fill" style="width: ${100 - percentage}%"></div>
</div>
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${stat.id}" title="Click to edit">${value}%</span>
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${stat.id}" data-max="${maxValue}" data-mode="${displayMode}" title="Click to edit">${displayValue}</span>
</div>
`;
}
@@ -307,13 +331,15 @@ export function renderUserStats() {
// Render custom status fields
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
// For now, use first field as "conditions" for backward compatibility
let conditionsValue = stats.conditions || 'None';
// Strip brackets if present (from JSON array format)
if (typeof conditionsValue === 'string') {
conditionsValue = conditionsValue.replace(/^\[|\]$/g, '').trim();
for (const fieldName of config.statusSection.customFields) {
const fieldKey = fieldName.toLowerCase();
let fieldValue = stats[fieldKey] || 'None';
// Strip brackets if present (from JSON array format)
if (typeof fieldValue === 'string') {
fieldValue = fieldValue.replace(/^\[|\]$/g, '').trim();
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="Click to edit ${fieldName}">${fieldValue}</div>`;
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${conditionsValue}</div>`;
}
html += '</div>';
@@ -405,14 +431,31 @@ export function renderUserStats() {
// Add event listeners for editable stat values
$('.rpg-editable-stat').on('blur', function() {
const field = $(this).data('field');
const textValue = $(this).text().replace('%', '').trim();
let value = parseInt(textValue);
const mode = $(this).data('mode');
const maxValue = parseInt($(this).data('max')) || 100;
const textValue = $(this).text().trim();
let value;
// Validate and clamp value between 0 and 100
if (isNaN(value)) {
value = 0;
if (mode === 'number') {
// In number mode, parse "X/MAX" or just "X"
const parts = textValue.split('/');
value = parseInt(parts[0]);
// Validate and clamp value between 0 and maxValue
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(maxValue, value));
} else {
// In percentage mode, parse "X%" or just "X"
value = parseInt(textValue.replace('%', ''));
// Validate and clamp value between 0 and 100
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(100, value));
}
value = Math.max(0, Math.min(100, value));
// Update the setting
extensionSettings.userStats[field] = value;
@@ -424,8 +467,9 @@ export function renderUserStats() {
saveChatData();
updateMessageSwipeData();
// Re-render to update the bar
// Re-render to update the bar and FAB widgets
renderUserStats();
updateFabWidgets();
});
// Add event listeners for mood/conditions editing
@@ -443,7 +487,8 @@ export function renderUserStats() {
$('.rpg-mood-conditions.rpg-editable').on('blur', function() {
const value = $(this).text().trim();
extensionSettings.userStats.conditions = value || 'None';
const fieldKey = $(this).data('field');
extensionSettings.userStats[fieldKey] = value || 'None';
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
+265 -2
View File
@@ -1,10 +1,273 @@
/**
* Desktop UI Module
* Handles desktop-specific UI functionality: tab navigation
* Handles desktop-specific UI functionality: tab navigation and strip widgets
*/
import { i18n } from '../../core/i18n.js';
import { extensionSettings } from '../../core/state.js';
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
/**
* Helper to parse time string and calculate clock hand angles
*/
function parseTimeForClock(timeStr) {
const timeMatch = timeStr.match(/(\d+):(\d+)/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
const minuteAngle = minutes * 6; // 6° per minute
return { hourAngle, minuteAngle };
}
return { hourAngle: 0, minuteAngle: 0 };
}
/**
* Updates the desktop strip widgets display based on current tracker data and settings.
* Strip widgets are shown vertically in the collapsed panel strip.
*/
export function updateStripWidgets() {
const $panel = $('#rpg-companion-panel');
const $container = $('#rpg-strip-widget-container');
if ($panel.length === 0 || $container.length === 0) return;
// Check if strip widgets are enabled
const widgetSettings = extensionSettings.desktopStripWidgets;
if (!widgetSettings || !widgetSettings.enabled) {
$panel.removeClass('rpg-strip-widgets-enabled');
$container.find('.rpg-strip-widget').removeClass('rpg-strip-widget-visible');
return;
}
// Add enabled class to panel for CSS styling (wider collapsed width)
$panel.addClass('rpg-strip-widgets-enabled');
// Get tracker data - use imported state directly
const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox;
// Parse infoBox if it's a string
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse infoBox:', e);
}
}
// Weather Icon Widget (with description)
const $weatherWidget = $container.find('.rpg-strip-widget-weather');
if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) {
$weatherWidget.find('.rpg-strip-widget-icon').text(infoData.weather.emoji);
// Show weather description truncated
const forecast = infoData.weather.forecast || '';
const displayForecast = forecast.length > 12 ? forecast.substring(0, 10) + '…' : forecast;
$weatherWidget.find('.rpg-strip-widget-desc').text(displayForecast);
$weatherWidget.attr('title', forecast || 'Weather');
$weatherWidget.addClass('rpg-strip-widget-visible');
} else {
$weatherWidget.removeClass('rpg-strip-widget-visible');
}
// Clock Widget with animated face
const $clockWidget = $container.find('.rpg-strip-widget-clock');
if (widgetSettings.clock?.enabled && infoData?.time) {
const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || '';
if (timeStr) {
// Update clock hands
const { hourAngle, minuteAngle } = parseTimeForClock(timeStr);
$clockWidget.find('.rpg-strip-clock-hour').css('transform', `rotate(${hourAngle}deg)`);
$clockWidget.find('.rpg-strip-clock-minute').css('transform', `rotate(${minuteAngle}deg)`);
$clockWidget.find('.rpg-strip-widget-value').text(timeStr);
$clockWidget.attr('title', `Time: ${timeStr}`);
$clockWidget.addClass('rpg-strip-widget-visible');
} else {
$clockWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$clockWidget.removeClass('rpg-strip-widget-visible');
}
// Date Widget
const $dateWidget = $container.find('.rpg-strip-widget-date');
if (widgetSettings.date?.enabled && infoData?.date?.value) {
const dateVal = infoData.date.value;
// Truncate long dates for display
const displayDate = dateVal.length > 20 ? dateVal.substring(0, 18) + '…' : dateVal;
$dateWidget.find('.rpg-strip-widget-value').text(displayDate);
$dateWidget.attr('title', dateVal);
$dateWidget.addClass('rpg-strip-widget-visible');
} else {
$dateWidget.removeClass('rpg-strip-widget-visible');
}
// Location Widget
const $locationWidget = $container.find('.rpg-strip-widget-location');
if (widgetSettings.location?.enabled && infoData?.location?.value) {
const loc = infoData.location.value;
// Truncate long locations for display
const displayLoc = loc.length > 15 ? loc.substring(0, 13) + '…' : loc;
$locationWidget.find('.rpg-strip-widget-value').text(displayLoc);
$locationWidget.attr('title', loc);
$locationWidget.addClass('rpg-strip-widget-visible');
} else {
$locationWidget.removeClass('rpg-strip-widget-visible');
}
// Stats Widget - get from lastGeneratedData or committedTrackerData first, fallback to extensionSettings
const $statsWidget = $container.find('.rpg-strip-widget-stats');
if (widgetSettings.stats?.enabled) {
let allStats = [];
// Try to get stats from tracker data first (most current)
const userStatsData = lastGeneratedData?.userStats || committedTrackerData?.userStats;
if (userStatsData) {
try {
const parsedStats = typeof userStatsData === 'string' ? JSON.parse(userStatsData) : userStatsData;
if (parsedStats?.stats) {
allStats = parsedStats.stats;
}
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse tracker userStats:', e);
}
}
// Fallback to extensionSettings.userStats
if (allStats.length === 0 && extensionSettings.userStats) {
try {
const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats;
}
} catch (e) {
console.warn('[RPG Strip Widgets] Failed to parse extensionSettings.userStats:', e);
}
}
if (allStats.length > 0) {
// Get enabled stats from trackerConfig
const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
const enabledStatMap = new Map();
configuredStats.forEach(s => {
if (s.enabled !== false) {
enabledStatMap.set(s.id?.toLowerCase(), true);
enabledStatMap.set(s.name?.toLowerCase(), true);
}
});
const $statsList = $statsWidget.find('.rpg-strip-stats-list');
$statsList.empty();
allStats.forEach(stat => {
// Filter by config if available - but if no config, show all
if (configuredStats.length > 0) {
const statId = stat.id?.toLowerCase();
const statName = stat.name?.toLowerCase();
if (!enabledStatMap.has(statId) && !enabledStatMap.has(statName)) return;
}
const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0;
const color = getStatColor(value);
const abbr = stat.name.substring(0, 3).toUpperCase();
const $item = $(`<div class="rpg-strip-stat-item" title="${stat.name}: ${value}">
<span class="rpg-strip-stat-name">${abbr}</span>
<span class="rpg-strip-stat-value" style="color: ${color};">${value}</span>
</div>`);
$statsList.append($item);
});
if ($statsList.children().length > 0) {
$statsWidget.addClass('rpg-strip-widget-visible');
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$statsWidget.removeClass('rpg-strip-widget-visible');
}
// Attributes Widget
const $attrsWidget = $container.find('.rpg-strip-widget-attributes');
if (widgetSettings.attributes?.enabled) {
const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false;
if (showRPGAttributes && extensionSettings.classicStats) {
// Get enabled attributes from trackerConfig
const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || [];
const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id);
const attrs = extensionSettings.classicStats;
const $attrsGrid = $attrsWidget.find('.rpg-strip-attributes-grid');
$attrsGrid.empty();
Object.entries(attrs).forEach(([key, value]) => {
// Filter by config if available
if (enabledAttrIds.length > 0 && !enabledAttrIds.includes(key.toLowerCase())) {
return;
}
const $item = $(`<div class="rpg-strip-attr-item" title="${key.toUpperCase()}: ${value}">
<span class="rpg-strip-attr-name">${key.toUpperCase()}</span>
<span class="rpg-strip-attr-value">${value}</span>
</div>`);
$attrsGrid.append($item);
});
if ($attrsGrid.children().length > 0) {
$attrsWidget.addClass('rpg-strip-widget-visible');
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
} else {
$attrsWidget.removeClass('rpg-strip-widget-visible');
}
}
/**
* Gets a color interpolated between low and high based on stat value (0-100).
* @param {number} value - The stat value (0-100)
* @returns {string} CSS color value
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
// Parse colors
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor;
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Converts a hex color to RGB object.
* @param {string} hex - Hex color string (e.g., "#cc3333")
* @returns {{r: number, g: number, b: number}|null}
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Sets up desktop tab navigation for organizing content.
+117 -41
View File
@@ -397,7 +397,7 @@ export class EncounterModal {
</div>
<!-- Player Controls -->
${this.renderPlayerControls(combatData.party)}
${this.renderPlayerControls(combatData.party, currentEncounter.playerActions)}
</div>
`;
@@ -599,7 +599,7 @@ export class EncounterModal {
if (member.isPlayer && user_avatar) {
avatarIcon = `<img src="${getSafeThumbnailUrl('persona', user_avatar)}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`;
} else {
const avatarUrl = this.getPartyMemberAvatar(member.name);
const avatarUrl = this.getCharacterAvatar(member.name);
if (avatarUrl) {
avatarIcon = `<img src="${avatarUrl}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`;
}
@@ -657,12 +657,16 @@ export class EncounterModal {
* @param {Array} party - Party data
* @returns {string} HTML for controls
*/
renderPlayerControls(party) {
renderPlayerControls(party, playerActions = null) {
const player = party.find(m => m.isPlayer);
if (!player || player.hp <= 0) {
return '<div class="rpg-encounter-controls"><p class="rpg-encounter-defeated">You have been defeated...</p></div>';
}
// Use playerActions if provided, otherwise fall back to player data
const attacks = playerActions?.attacks || player.attacks || [];
const items = playerActions?.items || player.items || [];
return `
<div class="rpg-encounter-controls">
<h3><i class="fa-solid fa-hand-fist"></i> Your Actions</h3>
@@ -670,7 +674,7 @@ export class EncounterModal {
<div class="rpg-encounter-action-buttons">
<div class="rpg-encounter-button-group">
<h4>Attacks</h4>
${player.attacks.map(attack => {
${attacks.map(attack => {
// Support both old string format and new object format
const attackName = typeof attack === 'string' ? attack : attack.name;
const attackType = typeof attack === 'string' ? 'single-target' : (attack.type || 'single-target');
@@ -688,10 +692,10 @@ export class EncounterModal {
}).join('')}
</div>
${player.items && player.items.length > 0 ? `
${items && items.length > 0 ? `
<div class="rpg-encounter-button-group">
<h4>Items</h4>
${player.items.map(item => `
${items.map(item => `
<button class="rpg-encounter-action-btn rpg-encounter-item-btn" data-action="item" data-value="${item}">
<i class="fa-solid fa-flask"></i> ${item}
</button>
@@ -718,21 +722,27 @@ export class EncounterModal {
* @param {Array} party - Party data for reference
*/
attachControlListeners(party) {
// Attack and item buttons
this.modal.querySelectorAll('.rpg-encounter-action-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const actionType = e.currentTarget.dataset.action;
const value = e.currentTarget.dataset.value;
const attackType = e.currentTarget.dataset.attackType;
// Only attach once - event delegation on the modal means listeners persist
if (this._listenersAttached) {
return;
}
// Store handlers as instance properties so we can remove them if needed
this._actionHandler = async (e) => {
// Handle action buttons (attack/item)
const actionBtn = e.target.closest('.rpg-encounter-action-btn');
if (actionBtn && !actionBtn.disabled && !this.isProcessing) {
const actionType = actionBtn.dataset.action;
const value = actionBtn.dataset.value;
const attackType = actionBtn.dataset.attackType;
const context = getContext();
const userName = context.name1;
let actionText = '';
if (actionType === 'attack') {
// Show target selection for attacks
const target = await this.showTargetSelection(attackType, currentEncounter.combatStats);
if (!target) return; // User cancelled
if (!target) return;
if (target === 'all-enemies') {
actionText = `${userName} uses ${value} targeting all enemies!`;
@@ -740,40 +750,46 @@ export class EncounterModal {
actionText = `${userName} uses ${value} on ${target}!`;
}
} else if (actionType === 'item') {
// Show target selection for items (default to single-target)
const target = await this.showTargetSelection('single-target', currentEncounter.combatStats);
if (!target) return; // User cancelled
if (!target) return;
actionText = `${userName} uses ${value} on ${target}!`;
}
await this.processCombatAction(actionText);
});
});
return;
}
// Custom action submit
const customInput = this.modal.querySelector('#rpg-encounter-custom-input');
const customSubmit = this.modal.querySelector('#rpg-encounter-custom-submit');
const submitCustomAction = async () => {
const action = customInput.value.trim();
if (!action) return;
await this.processCombatAction(action);
customInput.value = '';
// Handle custom submit button
const submitBtn = e.target.closest('#rpg-encounter-custom-submit');
if (submitBtn && !submitBtn.disabled && !this.isProcessing) {
const input = this.modal.querySelector('#rpg-encounter-custom-input');
if (input) {
const action = input.value.trim();
if (action) {
await this.processCombatAction(action);
input.value = '';
}
}
}
};
if (customSubmit) {
customSubmit.addEventListener('click', submitCustomAction);
}
if (customInput) {
customInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
submitCustomAction();
this._keypressHandler = async (e) => {
const input = e.target.closest('#rpg-encounter-custom-input');
if (input && e.key === 'Enter' && !this.isProcessing) {
const action = input.value.trim();
if (action) {
await this.processCombatAction(action);
input.value = '';
}
});
}
}
};
// Attach to the modal itself (which never gets replaced)
this.modal.addEventListener('click', this._actionHandler);
this.modal.addEventListener('keypress', this._keypressHandler);
this._listenersAttached = true;
}
/**
@@ -820,7 +836,8 @@ export class EncounterModal {
// Update encounter state
updateCurrentEncounter({
combatStats: result.combatStats
combatStats: result.combatStats,
playerActions: result.playerActions
});
// Collect log entries in order: enemy actions, party actions, then narration
@@ -935,16 +952,75 @@ export class EncounterModal {
}
});
// Re-render controls if player died
// Re-render controls if player died OR if player's actions changed
const player = combatStats.party.find(m => m.isPlayer);
const controlsContainer = this.modal.querySelector('.rpg-encounter-controls');
if (player && player.hp <= 0) {
const controlsContainer = this.modal.querySelector('.rpg-encounter-controls');
if (controlsContainer) {
controlsContainer.innerHTML = '<p class="rpg-encounter-defeated">You have been defeated...</p>';
}
} else if (currentEncounter.playerActions && controlsContainer) {
// Check if actions have changed by comparing with previous state
const actionsChanged = this.haveActionsChanged(currentEncounter.playerActions);
if (actionsChanged) {
// Store the new actions for next comparison
this._previousPlayerActions = {
attacks: currentEncounter.playerActions.attacks ? JSON.parse(JSON.stringify(currentEncounter.playerActions.attacks)) : [],
items: currentEncounter.playerActions.items ? [...currentEncounter.playerActions.items] : []
};
// Re-render the entire controls section with new actions
const newControlsHTML = this.renderPlayerControls(combatStats.party, currentEncounter.playerActions);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newControlsHTML;
const newControls = tempDiv.firstElementChild;
if (newControls) {
controlsContainer.replaceWith(newControls);
}
}
}
}
/**
* Checks if player's available actions have changed
* @param {Object} playerActions - Current player actions data with attacks and items
* @returns {boolean} True if actions changed
*/
haveActionsChanged(playerActions) {
if (!this._previousPlayerActions) {
// First time - store initial actions
this._previousPlayerActions = {
attacks: playerActions.attacks ? JSON.parse(JSON.stringify(playerActions.attacks)) : [],
items: playerActions.items ? [...playerActions.items] : []
};
return false;
}
const currentAttacks = playerActions.attacks || [];
const currentItems = playerActions.items || [];
const prevAttacks = this._previousPlayerActions.attacks || [];
const prevItems = this._previousPlayerActions.items || [];
// Check if attacks changed
if (currentAttacks.length !== prevAttacks.length) return true;
for (let i = 0; i < currentAttacks.length; i++) {
const curr = typeof currentAttacks[i] === 'string' ? currentAttacks[i] : currentAttacks[i].name;
const prev = typeof prevAttacks[i] === 'string' ? prevAttacks[i] : prevAttacks[i].name;
if (curr !== prev) return true;
}
// Check if items changed
if (currentItems.length !== prevItems.length) return true;
for (let i = 0; i < currentItems.length; i++) {
if (currentItems[i] !== prevItems[i]) return true;
}
return false;
}
/**
* Adds multiple log entries sequentially with animation
* @param {Array} entries - Array of {message, type} objects
+7 -1
View File
@@ -19,7 +19,7 @@ import {
} from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { setupMobileTabs, removeMobileTabs } from './mobile.js';
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
import { setupDesktopTabs, removeDesktopTabs, updateStripWidgets } from './desktop.js';
/**
* Toggles the visibility of plot buttons based on settings.
@@ -243,6 +243,9 @@ export function setupCollapseToggle() {
} else if ($panel.hasClass('rpg-position-left')) {
$icon.removeClass('fa-chevron-left').addClass('fa-chevron-right');
}
// Update strip widgets when collapsing (they show in collapsed state)
updateStripWidgets();
}
});
@@ -431,6 +434,7 @@ export function updateGenerationModeUI() {
if (extensionSettings.generationMode === 'together') {
// In "together" mode, manual update button is hidden
$('#rpg-manual-update').hide();
$('#rpg-strip-refresh').hide();
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideUp(200);
// Hide auto-update toggle (not applicable in together mode)
@@ -438,6 +442,7 @@ export function updateGenerationModeUI() {
} else if (extensionSettings.generationMode === 'separate') {
// In "separate" mode, manual update button is visible
$('#rpg-manual-update').show();
$('#rpg-strip-refresh').show();
$('#rpg-external-api-settings').slideUp(200);
$('#rpg-separate-mode-settings').slideDown(200);
// Show auto-update toggle
@@ -445,6 +450,7 @@ export function updateGenerationModeUI() {
} else if (extensionSettings.generationMode === 'external') {
// In "external" mode, manual update button is visible AND both settings are shown
$('#rpg-manual-update').show();
$('#rpg-strip-refresh').show();
$('#rpg-external-api-settings').slideDown(200);
$('#rpg-separate-mode-settings').slideDown(200);
// Show auto-update toggle for external mode too
+404 -3
View File
@@ -3,7 +3,7 @@
* Handles mobile-specific UI functionality: FAB dragging, tabs, keyboard handling
*/
import { extensionSettings } from '../../core/state.js';
import { extensionSettings, committedTrackerData, lastGeneratedData } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layout.js';
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
@@ -106,6 +106,14 @@ export function setupMobileToggle() {
right: 'auto',
bottom: 'auto'
});
// Also update widget container position during drag
const $container = $('#rpg-fab-widget-container');
if ($container.length > 0) {
$container.css({
top: pendingY + 'px',
left: pendingX + 'px'
});
}
pendingX = null;
pendingY = null;
}
@@ -253,7 +261,10 @@ export function setupMobileToggle() {
// console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition);
// Constrain to viewport bounds (now that position is saved)
setTimeout(() => constrainFabToViewport(), 10);
setTimeout(() => {
constrainFabToViewport();
updateFabWidgetPosition(); // Update widget container position
}, 10);
// Re-enable transitions with smooth animation
setTimeout(() => {
@@ -294,7 +305,10 @@ export function setupMobileToggle() {
// console.log('[RPG Mobile] Saved new FAB position:', newPosition);
// Constrain to viewport bounds (now that position is saved)
setTimeout(() => constrainFabToViewport(), 10);
setTimeout(() => {
constrainFabToViewport();
updateFabWidgetPosition(); // Update widget container position
}, 10);
// Re-enable transitions with smooth animation
setTimeout(() => {
@@ -1230,3 +1244,390 @@ export function setupDebugButtonDrag() {
isDragging = false;
});
}
// ============================================
// FAB WIDGETS - Info display around FAB button
// ============================================
/**
* Updates the FAB widgets display based on current tracker data and settings.
* Widgets are positioned in 8 positions around the FAB (N, NE, E, SE, S, SW, W, NW).
*/
export function updateFabWidgets() {
const $fab = $('#rpg-mobile-toggle');
if ($fab.length === 0) return;
// Remove existing widget container and clean up event listeners
$('#rpg-fab-widget-container').remove();
$(document).off('click.fabWidgets touchstart.fabWidgets');
// Check if widgets are enabled
const widgetSettings = extensionSettings.mobileFabWidgets;
if (!widgetSettings || !widgetSettings.enabled) return;
// Don't show widgets on desktop or when panel is open
if (window.innerWidth > 1000) return;
// Get tracker data - prefer lastGeneratedData (most recent) over committedTrackerData
const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox;
const userStats = lastGeneratedData?.userStats || committedTrackerData?.userStats;
// Parse infoBox if it's a string
let infoData = null;
if (infoBox) {
try {
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse infoBox:', e);
}
}
// Parse userStats if it's a string
let statsData = null;
if (userStats) {
try {
statsData = typeof userStats === 'string' ? JSON.parse(userStats) : userStats;
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse userStats:', e);
}
}
// Create widget container positioned at FAB location
const fabOffset = $fab.offset();
const fabWidth = $fab.outerWidth();
const fabHeight = $fab.outerHeight();
const $container = $('<div id="rpg-fab-widget-container" class="rpg-fab-widget-container"></div>');
$container.css({
top: fabOffset.top + 'px',
left: fabOffset.left + 'px',
width: fabWidth + 'px',
height: fabHeight + 'px'
});
// Build widgets based on settings - auto-assign positions sequentially
const widgets = [];
// Collect enabled widgets in display priority order
// Large widgets (Stats, Attributes) go to West/Northwest
// Small widgets spread around other positions
// Weather Icon (small)
if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) {
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-weather-icon" title="Weather">${infoData.weather.emoji}</div>`
});
}
// Weather Description (small)
if (widgetSettings.weatherDesc?.enabled && infoData?.weather?.forecast) {
const desc = infoData.weather.forecast.length > 15 ? infoData.weather.forecast.substring(0, 13) + '…' : infoData.weather.forecast;
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-weather-desc" title="${infoData.weather.forecast}">${desc}</div>`
});
}
// Helper to create expandable text widget HTML
const createExpandableText = (fullText, maxLen, emoji) => {
if (fullText.length <= maxLen) {
return `${emoji} ${fullText}`;
}
const truncated = fullText.substring(0, maxLen - 2) + '…';
return `${emoji} <span class="rpg-truncated">${truncated}</span><span class="rpg-full-text">${fullText}</span>`;
};
// Check if text needs truncation for data attribute
const needsExpand = (text, maxLen) => text.length > maxLen;
// Helper to parse time string and calculate clock hand angles
const parseTimeForClock = (timeStr) => {
const timeMatch = timeStr.match(/(\d+):(\d+)/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
const minuteAngle = minutes * 6; // 6° per minute
return { hourAngle, minuteAngle };
}
return { hourAngle: 0, minuteAngle: 0 };
};
// Clock/Time (bottom position with animated clock face)
if (widgetSettings.clock?.enabled && infoData?.time) {
const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || '';
if (timeStr) {
const { hourAngle, minuteAngle } = parseTimeForClock(timeStr);
widgets.push({
type: 'bottom', // Special type for bottom position
html: `<div class="rpg-fab-widget rpg-fab-widget-clock" title="${timeStr}">
<div class="rpg-fab-clock-face">
<div class="rpg-fab-clock-hour" style="transform: rotate(${hourAngle}deg)"></div>
<div class="rpg-fab-clock-minute" style="transform: rotate(${minuteAngle}deg)"></div>
<div class="rpg-fab-clock-center"></div>
</div>
<span class="rpg-fab-clock-time">${timeStr}</span>
</div>`
});
}
}
// Date (small)
if (widgetSettings.date?.enabled && infoData?.date?.value) {
const dateVal = infoData.date.value;
const expandAttr = needsExpand(dateVal, 12) ? ' data-full-text="true"' : '';
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-date"${expandAttr} title="${dateVal}">${createExpandableText(dateVal, 12, '📅')}</div>`
});
}
// Location (small)
if (widgetSettings.location?.enabled && infoData?.location?.value) {
const loc = infoData.location.value;
const expandAttr = needsExpand(loc, 14) ? ' data-full-text="true"' : '';
widgets.push({
type: 'small',
html: `<div class="rpg-fab-widget rpg-fab-widget-location"${expandAttr} title="${loc}">${createExpandableText(loc, 14, '📍')}</div>`
});
}
// Stats (large - goes to West) - respects trackerConfig.userStats.customStats
// Use extensionSettings.userStats as primary source (contains all stats), fallback to committedTrackerData
let allStats = [];
try {
const userStatsJson = extensionSettings.userStats;
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
if (parsedUserStats?.stats) {
allStats = parsedUserStats.stats;
}
} catch (e) {
console.warn('[RPG FAB Widgets] Failed to parse extensionSettings.userStats:', e);
}
// Fallback to statsData if extensionSettings.userStats is empty
if (allStats.length === 0 && statsData?.stats) {
allStats = statsData.stats;
}
if (widgetSettings.stats?.enabled && allStats.length > 0) {
// Get enabled stats from trackerConfig - match by id (lowercase)
const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
const enabledStatMap = new Map();
configuredStats.forEach(s => {
if (s.enabled !== false) {
enabledStatMap.set(s.id?.toLowerCase(), true);
enabledStatMap.set(s.name?.toLowerCase(), true);
}
});
const statsHtml = allStats
.filter(s => {
// If no config, show all stats
if (configuredStats.length === 0) return true;
// Check if stat is enabled in trackerConfig (match by id or name, case-insensitive)
const statId = s.id?.toLowerCase();
const statName = s.name?.toLowerCase();
return enabledStatMap.has(statId) || enabledStatMap.has(statName);
})
.map(stat => {
const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0;
const color = getStatColor(value);
const abbr = stat.name.substring(0, 3).toUpperCase();
return `<span class="rpg-fab-widget-stat-item" title="${stat.name}: ${value}" style="color: ${color};">${abbr}:${value}</span>`;
})
.join('');
if (statsHtml) {
widgets.push({
type: 'large',
preferredPos: 6, // West
html: `<div class="rpg-fab-widget rpg-fab-widget-stats"><div class="rpg-fab-widget-stats-row">${statsHtml}</div></div>`
});
}
}
// RPG Attributes (large - goes to Northwest) - respects trackerConfig.userStats.rpgAttributes
if (widgetSettings.attributes?.enabled) {
// Check if RPG attributes are enabled in trackerConfig
const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false;
if (showRPGAttributes && extensionSettings.classicStats) {
// Get enabled attributes from trackerConfig
const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || [];
const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id);
const attrs = extensionSettings.classicStats;
const attrItems = Object.entries(attrs)
.filter(([key]) => {
// Check if attribute is enabled in trackerConfig
if (enabledAttrIds.length > 0) {
return enabledAttrIds.includes(key.toLowerCase());
}
return true;
})
.map(([key, value]) => `<div class="rpg-fab-widget-attr-item"><span class="rpg-fab-widget-attr-name">${key.toUpperCase()}</span><span class="rpg-fab-widget-attr-value">${value}</span></div>`)
.join('');
if (attrItems) {
widgets.push({
type: 'large',
preferredPos: 7, // Northwest
html: `<div class="rpg-fab-widget rpg-fab-widget-attributes" title="Attributes"><div class="rpg-fab-widget-attr-grid">${attrItems}</div></div>`
});
}
}
}
// Auto-assign positions intelligently
// Large widgets get their preferred positions first (West=6, Northwest=7)
// Bottom widgets get position 4 (South)
// Small widgets fill remaining positions clockwise from North (0)
const usedPositions = new Set();
const positionedWidgets = [];
// Position order for small widgets: N(0), NE(1), E(2), SE(3), SW(5) - skip S(4) for bottom/clock
const smallPositionOrder = [0, 1, 2, 3, 5];
let smallPosIndex = 0;
// Check if only one large widget exists (for centering)
const largeWidgets = widgets.filter(w => w.type === 'large');
const singleLargeWidget = largeWidgets.length === 1;
// First: assign bottom widgets to position 4 (South)
widgets.filter(w => w.type === 'bottom').forEach(w => {
const pos = 4; // South position
usedPositions.add(pos);
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Second: assign large widgets to their preferred positions
largeWidgets.forEach(w => {
let pos = w.preferredPos;
// If preferred position is taken, find next available from large positions
if (usedPositions.has(pos)) {
pos = pos === 6 ? 7 : 6; // Try the other large position
}
usedPositions.add(pos);
// Add centered class if this is the only large widget
const centeredClass = singleLargeWidget ? ' rpg-fab-widget-centered' : '';
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}${centeredClass}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Third: assign small widgets to remaining positions
widgets.filter(w => w.type === 'small').forEach(w => {
// Find next available position from small position order
while (smallPosIndex < smallPositionOrder.length && usedPositions.has(smallPositionOrder[smallPosIndex])) {
smallPosIndex++;
}
const pos = smallPosIndex < smallPositionOrder.length ? smallPositionOrder[smallPosIndex] : (smallPosIndex % 8);
usedPositions.add(pos);
smallPosIndex++;
const finalHtml = w.html.replace('class="rpg-fab-widget', `class="rpg-fab-widget rpg-fab-widget-pos-${pos}`);
positionedWidgets.push({ position: pos, html: finalHtml });
});
// Add widgets to container
positionedWidgets.forEach(w => $container.append(w.html));
// Append container to body
if (positionedWidgets.length > 0) {
$('body').append($container);
// Add mobile tap handler for expandable widgets
$container.find('.rpg-fab-widget[data-full-text]').on('click touchstart', function(e) {
e.stopPropagation();
const $this = $(this);
const wasExpanded = $this.hasClass('expanded');
// Collapse all other expanded widgets
$container.find('.rpg-fab-widget.expanded').removeClass('expanded');
// Toggle this one
if (!wasExpanded) {
$this.addClass('expanded');
}
});
// Collapse on tap outside
$(document).on('click.fabWidgets touchstart.fabWidgets', function(e) {
if (!$(e.target).closest('.rpg-fab-widget').length) {
$container.find('.rpg-fab-widget.expanded').removeClass('expanded');
}
});
}
}
/**
* Gets a color for a stat value (0-100) using a gradient from low to high.
* @param {number} value - The stat value (0-100)
* @returns {string} CSS color value
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
// Parse colors
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor;
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
return `rgb(${r}, ${g}, ${b})`;
}
/**
* Converts a hex color to RGB object.
* @param {string} hex - Hex color string (e.g., "#cc3333")
* @returns {{r: number, g: number, b: number}|null}
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Updates the FAB widget container position to match FAB button position.
* Call this after FAB is dragged.
*/
export function updateFabWidgetPosition() {
const $fab = $('#rpg-mobile-toggle');
const $container = $('#rpg-fab-widget-container');
if ($fab.length === 0 || $container.length === 0) return;
const fabOffset = $fab.offset();
$container.css({
top: fabOffset.top + 'px',
left: fabOffset.left + 'px'
});
}
/**
* Sets the FAB loading state (spinning animation during API requests).
* @param {boolean} loading - Whether to show loading state
*/
export function setFabLoadingState(loading) {
const $fab = $('#rpg-mobile-toggle');
if ($fab.length === 0) return;
if (loading) {
$fab.addClass('rpg-fab-loading');
} else {
$fab.removeClass('rpg-fab-loading');
}
}
+23 -5
View File
@@ -4,7 +4,7 @@
*/
import { extensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_SPOTIFY_PROMPT, DEFAULT_NARRATOR_PROMPT } from '../generation/promptBuilder.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_CYOA_PROMPT, DEFAULT_SPOTIFY_PROMPT, DEFAULT_NARRATOR_PROMPT } from '../generation/promptBuilder.js';
let $editorModal = null;
let tempPrompts = null; // Temporary prompts for cancel functionality
@@ -13,6 +13,8 @@ let tempPrompts = null; // Temporary prompts for cancel functionality
const DEFAULT_PROMPTS = {
html: DEFAULT_HTML_PROMPT,
dialogueColoring: DEFAULT_DIALOGUE_COLORING_PROMPT,
deception: DEFAULT_DECEPTION_PROMPT,
cyoa: DEFAULT_CYOA_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.',
@@ -26,7 +28,7 @@ Next, detail the facial specifics. Describe the character's current expression,
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. 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).',
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 "😊". DO NOT include {userName} in the characters section, only NPCs. 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 placeholders (e.g., "Location", "Mood Emoji") MUST be replaced with actual content.',
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?"'
};
@@ -46,7 +48,7 @@ export function initPromptsEditor() {
$(document).on('click', '#rpg-prompts-save', function() {
savePrompts();
closePromptsEditor();
toastr.success('Prompts saved successfully');
toastr.success('Prompts saved successfully.');
});
// Cancel button
@@ -62,14 +64,14 @@ export function initPromptsEditor() {
// Restore All button
$(document).on('click', '#rpg-prompts-restore-all', function() {
restoreAllToDefaults();
toastr.success('All prompts restored to defaults');
toastr.success('All prompts restored to defaults.');
});
// Individual restore buttons
$(document).on('click', '.rpg-restore-prompt-btn', function() {
const promptType = $(this).data('prompt');
restorePromptToDefault(promptType);
toastr.success('Prompt restored to default');
toastr.success('Prompt restored to default.');
});
// Close on background click
@@ -93,6 +95,8 @@ function openPromptsEditor() {
tempPrompts = {
html: extensionSettings.customHtmlPrompt || '',
dialogueColoring: extensionSettings.customDialogueColoringPrompt || '',
deception: extensionSettings.customDeceptionPrompt || '',
cyoa: extensionSettings.customCYOAPrompt || '',
spotify: extensionSettings.customSpotifyPrompt || '',
narrator: extensionSettings.customNarratorPrompt || '',
plotRandom: extensionSettings.customPlotRandomPrompt || '',
@@ -106,6 +110,8 @@ function openPromptsEditor() {
// Load current values or defaults
$('#rpg-prompt-html').val(extensionSettings.customHtmlPrompt || DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(extensionSettings.customDialogueColoringPrompt || DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(extensionSettings.customDeceptionPrompt || DEFAULT_PROMPTS.deception);
$('#rpg-prompt-cyoa').val(extensionSettings.customCYOAPrompt || DEFAULT_PROMPTS.cyoa);
$('#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);
@@ -143,6 +149,8 @@ function closePromptsEditor() {
function savePrompts() {
extensionSettings.customHtmlPrompt = $('#rpg-prompt-html').val().trim();
extensionSettings.customDialogueColoringPrompt = $('#rpg-prompt-dialogue-coloring').val().trim();
extensionSettings.customDeceptionPrompt = $('#rpg-prompt-deception').val().trim();
extensionSettings.customCYOAPrompt = $('#rpg-prompt-cyoa').val().trim();
extensionSettings.customSpotifyPrompt = $('#rpg-prompt-spotify').val().trim();
extensionSettings.customNarratorPrompt = $('#rpg-prompt-narrator').val().trim();
extensionSettings.customPlotRandomPrompt = $('#rpg-prompt-plot-random').val().trim();
@@ -171,6 +179,12 @@ function restorePromptToDefault(promptType) {
case 'dialogueColoring':
extensionSettings.customDialogueColoringPrompt = '';
break;
case 'deception':
extensionSettings.customDeceptionPrompt = '';
break;
case 'cyoa':
extensionSettings.customCYOAPrompt = '';
break;
case 'spotify':
extensionSettings.customSpotifyPrompt = '';
break;
@@ -206,6 +220,8 @@ function restorePromptToDefault(promptType) {
function restoreAllToDefaults() {
$('#rpg-prompt-html').val(DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(DEFAULT_PROMPTS.deception);
$('#rpg-prompt-cyoa').val(DEFAULT_PROMPTS.cyoa);
$('#rpg-prompt-spotify').val(DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-plot-random').val(DEFAULT_PROMPTS.plotRandom);
@@ -218,6 +234,8 @@ function restoreAllToDefaults() {
// Clear all custom prompts
extensionSettings.customHtmlPrompt = '';
extensionSettings.customDialogueColoringPrompt = '';
extensionSettings.customDeceptionPrompt = '';
extensionSettings.customCYOAPrompt = '';
extensionSettings.customSpotifyPrompt = '';
extensionSettings.customNarratorPrompt = '';
extensionSettings.customPlotRandomPrompt = '';
+6
View File
@@ -138,6 +138,8 @@ export function updateFeatureTogglesVisibility() {
const $featuresRow = $('#rpg-features-row');
const $htmlToggle = $('#rpg-html-toggle-wrapper');
const $dialogueColoringToggle = $('#rpg-dialogue-coloring-toggle-wrapper');
const $deceptionToggle = $('#rpg-deception-toggle-wrapper');
const $cyoaToggle = $('#rpg-cyoa-toggle-wrapper');
const $spotifyToggle = $('#rpg-spotify-toggle-wrapper');
const $dynamicWeatherToggle = $('#rpg-dynamic-weather-toggle-wrapper');
@@ -147,6 +149,8 @@ export function updateFeatureTogglesVisibility() {
// Show/hide individual toggles
$htmlToggle.toggle(extensionSettings.showHtmlToggle);
$dialogueColoringToggle.toggle(extensionSettings.showDialogueColoringToggle);
$deceptionToggle.toggle(extensionSettings.showDeceptionToggle ?? true);
$cyoaToggle.toggle(extensionSettings.showCYOAToggle ?? true);
$spotifyToggle.toggle(extensionSettings.showSpotifyToggle);
$dynamicWeatherToggle.toggle(extensionSettings.showDynamicWeatherToggle);
@@ -156,6 +160,8 @@ export function updateFeatureTogglesVisibility() {
// Hide entire row if all toggles are hidden
const anyVisible = extensionSettings.showHtmlToggle ||
extensionSettings.showDialogueColoringToggle ||
(extensionSettings.showDeceptionToggle ?? true) ||
(extensionSettings.showCYOAToggle ?? true) ||
extensionSettings.showSpotifyToggle ||
extensionSettings.showDynamicWeatherToggle ||
extensionSettings.showNarratorMode ||
+693 -48
View File
@@ -4,14 +4,39 @@
*/
import { i18n } from '../../core/i18n.js';
import { extensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import {
saveSettings,
getPresets,
getPreset,
getActivePresetId,
getDefaultPresetId,
setDefaultPreset,
isDefaultPreset,
createPreset,
saveToPreset,
loadPreset,
renamePreset,
deletePreset,
associatePresetWithCurrentEntity,
removePresetAssociationForCurrentEntity,
getPresetForCurrentEntity,
hasPresetAssociation,
isAssociatedWithCurrentPreset,
getCurrentEntityKey,
getCurrentEntityName,
exportPresets,
importPresets
} from '../../core/persistence.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts } from '../rendering/thoughts.js';
import { updateFabWidgets } from './mobile.js';
let $editorModal = null;
let activeTab = 'userStats';
let tempConfig = null; // Temporary config for cancel functionality
let tempAssociation = null; // Temporary association state: { presetId: string|null, entityKey: string|null }
let originalAssociation = null; // Original association when editor opened
/**
* Initialize the tracker editor modal
@@ -78,6 +103,148 @@ export function initTrackerEditor() {
$(document).on('click', '#rpg-editor-import', function() {
importTrackerPreset();
});
// Preset select change
$(document).on('change', '#rpg-preset-select', function() {
const presetId = $(this).val();
if (presetId && presetId !== getActivePresetId()) {
// Check if the current character had an association (either original or pending)
const entityKey = getCurrentEntityKey();
const wasAssociated = tempAssociation
? tempAssociation.presetId !== null
: hasPresetAssociation();
// Save current changes to the old preset before switching
const currentPresetId = getActivePresetId();
if (currentPresetId) {
saveToPreset(currentPresetId);
}
// Load the new preset
if (loadPreset(presetId)) {
tempConfig = JSON.parse(JSON.stringify(extensionSettings.trackerConfig));
renderEditorUI();
// If the character was associated with a preset, update temp association to new preset
if (wasAssociated && entityKey) {
tempAssociation = { presetId: presetId, entityKey: entityKey };
const preset = getPreset(presetId);
toastr.info(`"${preset?.name || 'Unknown'}" will be associated with ${getCurrentEntityName()} when saved.`);
} else {
toastr.success(`Switched to preset "${getPreset(presetId)?.name || 'Unknown'}".`);
}
updatePresetUI();
}
}
});
// New preset button
$(document).on('click', '#rpg-preset-new', function() {
const name = prompt('Enter a name for the new preset:');
if (name && name.trim()) {
const newId = createPreset(name.trim());
updatePresetUI();
$('#rpg-preset-select').val(newId);
toastr.success(`Created preset "${name.trim()}".`);
}
});
// Set as default preset button
$(document).on('click', '#rpg-preset-default', function() {
const currentPresetId = getActivePresetId();
if (currentPresetId) {
setDefaultPreset(currentPresetId);
updatePresetUI();
const preset = getPreset(currentPresetId);
toastr.success(`"${preset?.name || 'Unknown'}" is now the default preset.`);
}
});
// Delete preset button
$(document).on('click', '#rpg-preset-delete', function() {
const currentPresetId = getActivePresetId();
const presets = getPresets();
if (Object.keys(presets).length <= 1) {
toastr.warning('Cannot delete the last preset.');
return;
}
const preset = getPreset(currentPresetId);
if (confirm(`Are you sure you want to delete the preset "${preset?.name || 'Unknown'}"?`)) {
if (deletePreset(currentPresetId)) {
tempConfig = JSON.parse(JSON.stringify(extensionSettings.trackerConfig));
renderEditorUI();
updatePresetUI();
toastr.success('Preset deleted.');
}
}
});
// Associate preset checkbox
$(document).on('change', '#rpg-preset-associate', function() {
const activePresetId = getActivePresetId();
const preset = getPreset(activePresetId);
const entityName = getCurrentEntityName();
const entityKey = getCurrentEntityKey();
if ($(this).is(':checked')) {
// Store pending association (don't save yet)
tempAssociation = { presetId: activePresetId, entityKey: entityKey };
toastr.info(`"${preset?.name || 'Unknown'}" will be associated with ${entityName} when saved.`);
} else {
// Store pending removal (don't save yet)
tempAssociation = { presetId: null, entityKey: entityKey };
const defaultPresetId = getDefaultPresetId();
const defaultPreset = getPreset(defaultPresetId);
if (defaultPreset && defaultPresetId !== activePresetId) {
toastr.info(`Association will be removed when saved. Default preset "${defaultPreset.name}" will apply on next character switch.`);
} else {
toastr.info(`Association will be removed for ${entityName} when saved.`);
}
}
});
}
/**
* Updates the preset management UI (dropdown, association checkbox, entity name)
*/
function updatePresetUI() {
const presets = getPresets();
const activePresetId = getActivePresetId();
const defaultPresetId = getDefaultPresetId();
const $select = $('#rpg-preset-select');
// Populate the dropdown
$select.empty();
for (const [id, preset] of Object.entries(presets)) {
const isDefault = id === defaultPresetId;
const starPrefix = isDefault ? '★ ' : '';
$select.append(`<option value="${id}">${starPrefix}${preset.name}</option>`);
}
$select.val(activePresetId);
// Update the default button appearance
const $defaultBtn = $('#rpg-preset-default');
if (isDefaultPreset(activePresetId)) {
$defaultBtn.addClass('rpg-btn-active').attr('title', 'This is the default preset');
} else {
$defaultBtn.removeClass('rpg-btn-active').attr('title', 'Set as Default Preset');
}
// Update the entity name display
const entityName = getCurrentEntityName();
$('#rpg-preset-entity-name').text(entityName);
// Update the association checkbox
// Use temp state if available, otherwise check actual association with CURRENT preset
let isAssociated;
if (tempAssociation !== null) {
// Use pending state: checked if pending preset matches active preset
isAssociated = tempAssociation.presetId === activePresetId;
} else {
// No pending changes, check actual state
isAssociated = isAssociatedWithCurrentPreset();
}
$('#rpg-preset-associate').prop('checked', isAssociated);
}
/**
@@ -87,10 +254,19 @@ function openTrackerEditor() {
// Create temporary copy for cancel functionality
tempConfig = JSON.parse(JSON.stringify(extensionSettings.trackerConfig));
// Store original association state for cancel functionality
const entityKey = getCurrentEntityKey();
const currentAssociatedPreset = getPresetForCurrentEntity();
originalAssociation = { presetId: currentAssociatedPreset, entityKey: entityKey };
tempAssociation = null; // Reset pending changes
// Set theme to match current extension theme
const theme = extensionSettings.theme || 'modern';
$editorModal.attr('data-theme', theme);
// Update preset UI
updatePresetUI();
renderEditorUI();
$editorModal.addClass('is-open').css('display', '');
}
@@ -105,6 +281,10 @@ function closeTrackerEditor() {
tempConfig = null;
}
// Discard pending association changes (cancel = no save)
tempAssociation = null;
originalAssociation = null;
$editorModal.removeClass('is-open').addClass('is-closing');
setTimeout(() => {
$editorModal.removeClass('is-closing').hide();
@@ -116,12 +296,35 @@ function closeTrackerEditor() {
*/
function applyTrackerConfig() {
tempConfig = null; // Clear temp config
saveSettings();
// Apply pending association changes
if (tempAssociation) {
if (tempAssociation.presetId !== null) {
// Associate with the pending preset
associatePresetWithCurrentEntity();
const preset = getPreset(tempAssociation.presetId);
toastr.success(`"${preset?.name || 'Unknown'}" is now associated with ${getCurrentEntityName()}.`);
} else {
// Remove association
removePresetAssociationForCurrentEntity();
}
tempAssociation = null;
}
originalAssociation = null;
// Save to the current preset
const currentPresetId = getActivePresetId();
if (currentPresetId) {
saveToPreset(currentPresetId);
} else {
saveSettings();
}
// Re-render all trackers with new config
renderUserStats();
renderInfoBox();
renderThoughts();
updateFabWidgets(); // Update FAB widgets to reflect new config
}
/**
@@ -131,40 +334,44 @@ function resetToDefaults() {
extensionSettings.trackerConfig = {
userStats: {
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'satiety', name: 'Satiety', enabled: true },
{ id: 'energy', name: 'Energy', enabled: true },
{ id: 'hygiene', name: 'Hygiene', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
{ id: 'health', name: 'Health', enabled: true, persistInHistory: false },
{ id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false },
{ id: 'energy', name: 'Energy', enabled: true, persistInHistory: false },
{ id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false },
{ id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false }
],
showRPGAttributes: true,
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 }
{ id: 'str', name: 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: 'WIS', enabled: true, persistInHistory: false },
{ id: 'cha', name: 'CHA', enabled: true, persistInHistory: false }
],
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions']
customFields: ['Conditions'],
persistInHistory: false
},
skillsSection: {
enabled: false,
label: 'Skills',
customFields: []
}
customFields: [],
persistInHistory: false
},
inventoryPersistInHistory: false,
questsPersistInHistory: false
},
infoBox: {
widgets: {
date: { enabled: true, format: 'Weekday, Month, Year' },
weather: { enabled: true },
temperature: { enabled: true, unit: 'C' },
time: { enabled: true },
location: { enabled: true },
recentEvents: { enabled: true }
date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true },
weather: { enabled: true, persistInHistory: true },
temperature: { enabled: true, unit: 'C', persistInHistory: false },
time: { enabled: true, persistInHistory: true },
location: { enabled: true, persistInHistory: true },
recentEvents: { enabled: true, persistInHistory: false }
}
},
presentCharacters: {
@@ -189,13 +396,14 @@ function resetToDefaults() {
'Neutral': '⚖️'
},
customFields: [
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)' },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state' }
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false }
],
thoughts: {
enabled: true,
name: 'Thoughts',
description: 'Internal monologue (in first person POV, up to three sentences long)'
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)',
persistInHistory: false
},
characterStats: {
enabled: false,
@@ -206,6 +414,14 @@ function resetToDefaults() {
}
}
};
// Reset history persistence settings
extensionSettings.historyPersistence = {
enabled: false,
messageCount: 5,
injectionPosition: 'assistant_message_end',
contextPreamble: '',
sendAllEnabledOnRefresh: false
};
}
/**
@@ -215,13 +431,15 @@ function exportTrackerPreset() {
try {
// Get the current tracker configuration
const config = extensionSettings.trackerConfig;
const historyPersistence = extensionSettings.historyPersistence;
// Create a preset object with metadata
const preset = {
name: 'Custom Tracker Preset',
version: '1.0',
version: '1.1', // Bumped version for historyPersistence support
exportDate: new Date().toISOString(),
trackerConfig: JSON.parse(JSON.stringify(config)) // Deep copy
trackerConfig: JSON.parse(JSON.stringify(config)), // Deep copy
historyPersistence: historyPersistence ? JSON.parse(JSON.stringify(historyPersistence)) : null // Include history persistence settings
};
// Convert to JSON
@@ -286,9 +504,67 @@ function migrateTrackerPreset(config) {
if (!migrated.presentCharacters.relationships.relationshipEmojis) {
migrated.presentCharacters.relationships.relationshipEmojis = {};
}
// Add persistInHistory to customFields if missing (v3.4.0)
if (migrated.presentCharacters.customFields) {
migrated.presentCharacters.customFields = migrated.presentCharacters.customFields.map(field => ({
...field,
persistInHistory: field.persistInHistory ?? false
}));
}
// Add persistInHistory to thoughts if missing (v3.4.0)
if (migrated.presentCharacters.thoughts && migrated.presentCharacters.thoughts.persistInHistory === undefined) {
migrated.presentCharacters.thoughts.persistInHistory = false;
}
}
// Add any other migration logic here for future format changes
// Add persistInHistory to userStats fields if missing (v3.4.0)
if (migrated.userStats) {
// Custom stats
if (migrated.userStats.customStats) {
migrated.userStats.customStats = migrated.userStats.customStats.map(stat => ({
...stat,
persistInHistory: stat.persistInHistory ?? false
}));
}
// RPG Attributes
if (migrated.userStats.rpgAttributes) {
migrated.userStats.rpgAttributes = migrated.userStats.rpgAttributes.map(attr => ({
...attr,
persistInHistory: attr.persistInHistory ?? false
}));
}
// Status section
if (migrated.userStats.statusSection && migrated.userStats.statusSection.persistInHistory === undefined) {
migrated.userStats.statusSection.persistInHistory = false;
}
// Skills section
if (migrated.userStats.skillsSection && migrated.userStats.skillsSection.persistInHistory === undefined) {
migrated.userStats.skillsSection.persistInHistory = false;
}
// Inventory and quests persistence
if (migrated.userStats.inventoryPersistInHistory === undefined) {
migrated.userStats.inventoryPersistInHistory = false;
}
if (migrated.userStats.questsPersistInHistory === undefined) {
migrated.userStats.questsPersistInHistory = false;
}
}
// Add persistInHistory to infoBox widgets if missing (v3.4.0)
if (migrated.infoBox && migrated.infoBox.widgets) {
for (const [widgetId, widget] of Object.entries(migrated.infoBox.widgets)) {
if (widget.persistInHistory === undefined) {
// Default to false for backwards compatibility - user must explicitly enable
widget.persistInHistory = false;
}
}
}
return migrated;
}
@@ -323,22 +599,11 @@ function importTrackerPreset() {
// Migrate old preset format to current format
const migratedConfig = migrateTrackerPreset(data.trackerConfig);
// Ask for confirmation
const confirmMessage = i18n.getTranslation('template.trackerEditorModal.messages.importConfirm') ||
'This will replace your current tracker configuration. Continue?';
// Extract historyPersistence if present in the import file
const historyPersistence = data.historyPersistence || null;
if (!confirm(confirmMessage)) {
return;
}
// Apply the migrated configuration
extensionSettings.trackerConfig = migratedConfig;
// Re-render the editor UI
renderEditorUI();
// console.log('[RPG Companion] Tracker preset imported successfully');
toastr.success(i18n.getTranslation('template.trackerEditorModal.messages.importSuccess') || 'Tracker preset imported successfully!');
// Show import mode selection dialog
showImportModeDialog(migratedConfig, data.name || file.name.replace('.json', ''), historyPersistence);
} catch (error) {
console.error('[RPG Companion] Error importing tracker preset:', error);
toastr.error(i18n.getTranslation('template.trackerEditorModal.messages.importError') ||
@@ -350,6 +615,101 @@ function importTrackerPreset() {
input.click();
}
/**
* Show dialog to choose import mode
* @param {Object} migratedConfig - The migrated tracker config
* @param {string} suggestedName - Suggested name for new preset
* @param {Object|null} historyPersistence - The history persistence settings from import (if any)
*/
function showImportModeDialog(migratedConfig, suggestedName, historyPersistence = null) {
// Create dialog overlay
const dialogHtml = `
<div id="rpg-import-mode-dialog" class="rpg-import-dialog-overlay">
<div class="rpg-import-dialog">
<h4><i class="fa-solid fa-file-import"></i> Import Configuration</h4>
<p>How would you like to import this configuration?</p>
<div class="rpg-import-dialog-buttons">
<button id="rpg-import-to-current" class="rpg-btn-secondary">
<i class="fa-solid fa-arrow-right-to-bracket"></i>
Apply to Current Preset
</button>
<button id="rpg-import-as-new" class="rpg-btn-primary">
<i class="fa-solid fa-plus"></i>
Create New Preset
</button>
</div>
<button id="rpg-import-cancel" class="rpg-btn-cancel">Cancel</button>
</div>
</div>
`;
$('body').append(dialogHtml);
const $dialog = $('#rpg-import-mode-dialog');
// Import to current preset
$('#rpg-import-to-current').on('click', () => {
$dialog.remove();
// Apply the migrated configuration to current
extensionSettings.trackerConfig = migratedConfig;
// Apply historyPersistence settings if present in import
if (historyPersistence) {
extensionSettings.historyPersistence = historyPersistence;
}
// Save to the active preset (saveToPreset uses current trackerConfig)
const activePresetId = getActivePresetId();
if (activePresetId) {
saveToPreset(activePresetId);
}
// Re-render the editor UI
renderEditorUI();
toastr.success('Configuration applied to current preset.');
});
// Import as new preset
$('#rpg-import-as-new').on('click', () => {
$dialog.remove();
// Prompt for preset name
const presetName = prompt('Enter a name for the new preset:', suggestedName);
if (!presetName) return;
// Set the migrated config as current first
extensionSettings.trackerConfig = migratedConfig;
// Apply historyPersistence settings if present in import
if (historyPersistence) {
extensionSettings.historyPersistence = historyPersistence;
}
// Create new preset (createPreset uses current trackerConfig)
const newPresetId = createPreset(presetName);
if (newPresetId) {
// Load the new preset
loadPreset(newPresetId);
renderEditorUI();
updatePresetUI();
toastr.success(`Created new preset: ${presetName}.`);
}
});
// Cancel
$('#rpg-import-cancel').on('click', () => {
$dialog.remove();
});
// Close on overlay click
$dialog.on('click', (e) => {
if (e.target === $dialog[0]) {
$dialog.remove();
}
});
}
/**
* Render the editor UI based on current config
*/
@@ -357,6 +717,7 @@ function renderEditorUI() {
renderUserStatsTab();
renderInfoBoxTab();
renderPresentCharactersTab();
renderHistoryPersistenceTab();
}
/**
@@ -368,13 +729,27 @@ function renderUserStatsTab() {
// Custom Stats section
html += `<h4><i class="fa-solid fa-heart-pulse"></i> ${i18n.getTranslation('template.trackerEditorModal.userStatsTab.customStatsTitle')}</h4>`;
// Stats display mode toggle
const statsDisplayMode = config.statsDisplayMode || 'percentage';
html += '<div class="rpg-editor-toggle-row">';
html += '<label>Display Mode:</label>';
html += '<div class="rpg-radio-group">';
html += `<label><input type="radio" name="stats-display-mode" value="percentage" ${statsDisplayMode === 'percentage' ? 'checked' : ''}> Percentage</label>`;
html += `<label><input type="radio" name="stats-display-mode" value="number" ${statsDisplayMode === 'number' ? 'checked' : ''}> Number</label>`;
html += '</div>';
html += '</div>';
html += '<div class="rpg-editor-stats-list" id="rpg-editor-stats-list">';
config.customStats.forEach((stat, index) => {
const showMaxValue = statsDisplayMode === 'number';
const maxValue = stat.maxValue || 100;
html += `
<div class="rpg-editor-stat-item" data-index="${index}">
<input type="checkbox" ${stat.enabled ? 'checked' : ''} class="rpg-stat-toggle" data-index="${index}">
<input type="text" value="${stat.name}" class="rpg-stat-name" data-index="${index}" placeholder="Stat Name">
<input type="number" value="${maxValue}" class="rpg-stat-max ${showMaxValue ? '' : 'rpg-hidden'}" data-index="${index}" placeholder="Max" min="1" step="1" title="Maximum value">
<button class="rpg-stat-remove" data-index="${index}" title="Remove stat"><i class="fa-solid fa-trash"></i></button>
</div>
`;
@@ -484,7 +859,8 @@ function setupUserStatsListeners() {
extensionSettings.trackerConfig.userStats.customStats.push({
id: newId,
name: 'New Stat',
enabled: true
enabled: true,
maxValue: 100
});
// Initialize value if doesn't exist
if (extensionSettings.userStats[newId] === undefined) {
@@ -512,6 +888,19 @@ function setupUserStatsListeners() {
extensionSettings.trackerConfig.userStats.customStats[index].name = $(this).val();
});
// Change stat max value
$('.rpg-stat-max').off('blur').on('blur', function() {
const index = $(this).data('index');
const value = parseInt($(this).val()) || 100;
extensionSettings.trackerConfig.userStats.customStats[index].maxValue = Math.max(1, value);
});
// Stats display mode toggle
$('input[name="stats-display-mode"]').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.statsDisplayMode = $(this).val();
renderUserStatsTab(); // Re-render to show/hide max value fields
});
// Add attribute
$('#rpg-add-attr').off('click').on('click', function() {
// Ensure rpgAttributes array exists with defaults if needed
@@ -618,9 +1007,7 @@ function renderInfoBoxTab() {
html += `<label for="rpg-widget-date">${i18n.getTranslation('template.trackerEditorModal.infoBoxTab.dateWidget')}</label>`;
html += '<select id="rpg-date-format" class="rpg-select-mini">';
html += `<option value="Weekday, Month, Year" ${config.widgets.date.format === 'Weekday, Month, Year' ? 'selected' : ''}>Weekday, Month, Year</option>`;
html += `<option value="dd/mm/yyyy" ${config.widgets.date.format === 'dd/mm/yyyy' ? 'selected' : ''}>dd/mm/yyyy</option>`;
html += `<option value="mm/dd/yyyy" ${config.widgets.date.format === 'mm/dd/yyyy' ? 'selected' : ''}>mm/dd/yyyy</option>`;
html += `<option value="yyyy-mm-dd" ${config.widgets.date.format === 'yyyy-mm-dd' ? 'selected' : ''}>yyyy-mm-dd</option>`;
html += `<option value="Day (Numerical), Month, Year" ${config.widgets.date.format === 'Day (Numerical), Month, Year' ? 'selected' : ''}>Day (Numerical), Month, Year</option>`;
html += '</select>';
html += '</div>';
@@ -783,7 +1170,7 @@ function renderPresentCharactersTab() {
html += '</div>';
html += '<div class="rpg-editor-input-group">';
html += `<label>${i18n.getTranslation('template.trackerEditorModal.presentCharactersTab.aiInstructionLabel')}</label>`;
html += `<input type="text" id="rpg-thoughts-description" value="${config.thoughts?.description || 'Internal monologue (in first person POV, up to three sentences long)'}" placeholder="Description of what to generate">`;
html += `<input type="text" id="rpg-thoughts-description" value="${config.thoughts?.description || 'Internal Monologue (in first person from character\'s POV, up to three sentences long)'}" placeholder="Description of what to generate">`;
html += '</div>';
html += '</div>';
@@ -1059,3 +1446,261 @@ function setupPresentCharactersListeners() {
extensionSettings.trackerConfig.presentCharacters.characterStats.customStats[index].name = $(this).val();
});
}
/**
* Render History Persistence configuration tab
* Allows users to select which tracker data should be injected into historical messages
*/
function renderHistoryPersistenceTab() {
const historyPersistence = extensionSettings.historyPersistence || {
enabled: false,
messageCount: 5,
injectionPosition: 'assistant_message_end',
contextPreamble: '',
sendAllEnabledOnRefresh: false
};
const userStatsConfig = extensionSettings.trackerConfig.userStats;
const infoBoxConfig = extensionSettings.trackerConfig.infoBox;
const presentCharsConfig = extensionSettings.trackerConfig.presentCharacters;
const generationMode = extensionSettings.generationMode || 'together';
let html = '<div class="rpg-editor-section">';
// Main toggle and settings
html += `<h4><i class="fa-solid fa-clock-rotate-left"></i> History Persistence Settings</h4>`;
html += `<p class="rpg-editor-hint">Inject selected tracker data into historical messages to help the AI maintain continuity for time-sensitive events, weather changes, and location tracking.</p>`;
// Enable toggle
html += '<div class="rpg-editor-toggle-row">';
html += `<input type="checkbox" id="rpg-history-persistence-enabled" ${historyPersistence.enabled ? 'checked' : ''}>`;
html += `<label for="rpg-history-persistence-enabled">Enable History Persistence</label>`;
html += '</div>';
// External API Only toggle - only show for separate/external modes
if (generationMode === 'separate' || generationMode === 'external') {
html += '<div class="rpg-editor-toggle-row" style="margin-top: 8px;">';
html += `<input type="checkbox" id="rpg-history-send-all-enabled" ${historyPersistence.sendAllEnabledOnRefresh ? 'checked' : ''}>`;
html += `<label for="rpg-history-send-all-enabled">Send All Enabled Stats on Refresh</label>`;
html += '</div>';
html += `<p class="rpg-editor-hint" style="margin-top: 4px; margin-left: 24px;">When enabled, Refresh RPG Info will include all enabled stats from the preset in history context, ignoring the individual selections below.</p>`;
}
// Message count
html += '<div class="rpg-editor-input-row" style="margin-top: 12px;">';
html += `<label for="rpg-history-message-count">Number of messages to include (0 = all available):</label>`;
html += `<input type="number" id="rpg-history-message-count" min="0" max="50" value="${historyPersistence.messageCount}" class="rpg-input" style="width: 80px; margin-left: 8px;">`;
html += '</div>';
// Injection position
html += '<div class="rpg-editor-input-row" style="margin-top: 12px;">';
html += `<label for="rpg-history-injection-position">Injection Position:</label>`;
html += `<select id="rpg-history-injection-position" class="rpg-select" style="margin-left: 8px;">`;
html += `<option value="user_message_end" ${historyPersistence.injectionPosition === 'user_message_end' ? 'selected' : ''}>End of the User's Message</option>`;
html += `<option value="assistant_message_end" ${historyPersistence.injectionPosition === 'assistant_message_end' ? 'selected' : ''}>End of the Assistant's Message</option>`;
html += `</select>`;
html += '</div>';
// Custom preamble
html += '<div class="rpg-editor-input-row" style="margin-top: 12px;">';
html += `<label for="rpg-history-context-preamble">Custom Context Preamble:</label>`;
html += `<input type="text" id="rpg-history-context-preamble" value="${historyPersistence.contextPreamble || ''}" class="rpg-text-input" placeholder="Context for that moment:" style="width: 100%; margin-top: 4px;">`;
html += '</div>';
// User Stats section - which stats to persist
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-heart-pulse"></i> User Stats</h4>`;
html += `<p class="rpg-editor-hint">Select which stats should be included in historical messages.</p>`;
// Custom stats
html += '<div class="rpg-history-persist-list">';
userStatsConfig.customStats.forEach((stat, index) => {
if (stat.enabled) {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-stat-${stat.id}" class="rpg-history-stat-toggle" data-index="${index}" ${stat.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-stat-${stat.id}">${stat.name}</label>
</div>
`;
}
});
// Status section
if (userStatsConfig.statusSection?.enabled) {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-status" ${userStatsConfig.statusSection.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-status">Status (Mood/Conditions)</label>
</div>
`;
}
// Skills section
if (userStatsConfig.skillsSection?.enabled) {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-skills" ${userStatsConfig.skillsSection.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-skills">${userStatsConfig.skillsSection.label || 'Skills'}</label>
</div>
`;
}
// Inventory
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-inventory" ${userStatsConfig.inventoryPersistInHistory ? 'checked' : ''}>
<label for="rpg-history-inventory">Inventory</label>
</div>
`;
// Quests
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-quests" ${userStatsConfig.questsPersistInHistory ? 'checked' : ''}>
<label for="rpg-history-quests">Quests</label>
</div>
`;
html += '</div>';
// Info Box section - which widgets to persist
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-info-circle"></i> Info Box</h4>`;
html += `<p class="rpg-editor-hint">Select which info box fields should be included in historical messages. These are recommended for time tracking.</p>`;
html += '<div class="rpg-history-persist-list">';
const widgetLabels = {
date: 'Date',
weather: 'Weather',
temperature: 'Temperature',
time: 'Time',
location: 'Location',
recentEvents: 'Recent Events'
};
for (const [widgetId, widget] of Object.entries(infoBoxConfig.widgets)) {
if (widget.enabled) {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-widget-${widgetId}" class="rpg-history-widget-toggle" data-widget="${widgetId}" ${widget.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-widget-${widgetId}">${widgetLabels[widgetId] || widgetId}</label>
</div>
`;
}
}
html += '</div>';
// Present Characters section
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-users"></i> Present Characters</h4>`;
html += `<p class="rpg-editor-hint">Select which character fields should be included in historical messages.</p>`;
html += '<div class="rpg-history-persist-list">';
// Custom fields (appearance, demeanor, etc.)
presentCharsConfig.customFields.forEach((field, index) => {
if (field.enabled) {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-charfield-${field.id}" class="rpg-history-charfield-toggle" data-index="${index}" ${field.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-charfield-${field.id}">${field.name}</label>
</div>
`;
}
});
// Thoughts
if (presentCharsConfig.thoughts?.enabled) {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-thoughts" ${presentCharsConfig.thoughts.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-thoughts">${presentCharsConfig.thoughts.name || 'Thoughts'}</label>
</div>
`;
}
html += '</div>';
html += '</div>';
$('#rpg-editor-tab-historyPersistence').html(html);
setupHistoryPersistenceListeners();
}
/**
* Set up event listeners for History Persistence tab
*/
function setupHistoryPersistenceListeners() {
// Ensure historyPersistence object exists
if (!extensionSettings.historyPersistence) {
extensionSettings.historyPersistence = {
enabled: false,
messageCount: 5,
injectionPosition: 'assistant_message_end',
contextPreamble: '',
externalApiOnly: false
};
}
// Main toggle
$('#rpg-history-persistence-enabled').off('change').on('change', function() {
extensionSettings.historyPersistence.enabled = $(this).is(':checked');
});
// Send All Enabled on Refresh toggle
$('#rpg-history-send-all-enabled').off('change').on('change', function() {
extensionSettings.historyPersistence.sendAllEnabledOnRefresh = $(this).is(':checked');
});
// Message count
$('#rpg-history-message-count').off('change').on('change', function() {
extensionSettings.historyPersistence.messageCount = parseInt($(this).val()) || 0;
});
// Injection position
$('#rpg-history-injection-position').off('change').on('change', function() {
extensionSettings.historyPersistence.injectionPosition = $(this).val();
});
// Context preamble
$('#rpg-history-context-preamble').off('blur').on('blur', function() {
extensionSettings.historyPersistence.contextPreamble = $(this).val();
});
// User Stats toggles
$('.rpg-history-stat-toggle').off('change').on('change', function() {
const index = $(this).data('index');
extensionSettings.trackerConfig.userStats.customStats[index].persistInHistory = $(this).is(':checked');
});
// Status section
$('#rpg-history-status').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.statusSection.persistInHistory = $(this).is(':checked');
});
// Skills section
$('#rpg-history-skills').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.skillsSection.persistInHistory = $(this).is(':checked');
});
// Inventory
$('#rpg-history-inventory').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.inventoryPersistInHistory = $(this).is(':checked');
});
// Quests
$('#rpg-history-quests').off('change').on('change', function() {
extensionSettings.trackerConfig.userStats.questsPersistInHistory = $(this).is(':checked');
});
// Info Box widget toggles
$('.rpg-history-widget-toggle').off('change').on('change', function() {
const widgetId = $(this).data('widget');
extensionSettings.trackerConfig.infoBox.widgets[widgetId].persistInHistory = $(this).is(':checked');
});
// Present Characters field toggles
$('.rpg-history-charfield-toggle').off('change').on('change', function() {
const index = $(this).data('index');
extensionSettings.trackerConfig.presentCharacters.customFields[index].persistInHistory = $(this).is(':checked');
});
// Thoughts
$('#rpg-history-thoughts').off('change').on('change', function() {
extensionSettings.trackerConfig.presentCharacters.thoughts.persistInHistory = $(this).is(':checked');
});
}
+365 -16
View File
@@ -4,9 +4,106 @@
*/
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { repairJSON } from '../../utils/jsonRepair.js';
let weatherContainer = null;
let currentWeatherType = null;
let currentTimeOfDay = null;
let currentHour = null;
/**
* Parse time string to extract hour (24-hour format)
* Supports formats like "3:00 PM", "15:00", "3 PM", "Evening", etc.
*/
function parseHourFromTime(timeStr) {
if (!timeStr) return null;
const text = timeStr.toLowerCase().trim();
// Check for descriptive time words first
if (text.includes('dawn') || text.includes('sunrise')) return 6;
if (text.includes('early morning')) return 7;
if (text.includes('morning')) return 9;
if (text.includes('midday') || text.includes('noon') || text.includes('mid-day')) return 12;
if (text.includes('afternoon')) return 14;
if (text.includes('late afternoon')) return 16;
if (text.includes('evening') || text.includes('dusk') || text.includes('sunset')) return 19;
if (text.includes('twilight')) return 20;
if (text.includes('night') || text.includes('nighttime')) return 22;
if (text.includes('midnight')) return 0;
if (text.includes('late night')) return 2;
// Try to parse numeric time formats
// Format: "3:00 PM" or "3:00PM" or "3 PM"
const ampmMatch = text.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
if (ampmMatch) {
let hour = parseInt(ampmMatch[1], 10);
const isPM = ampmMatch[3].toLowerCase() === 'pm';
if (isPM && hour !== 12) hour += 12;
if (!isPM && hour === 12) hour = 0;
return hour;
}
// Format: "15:00" (24-hour)
const militaryMatch = text.match(/(\d{1,2}):(\d{2})/);
if (militaryMatch) {
return parseInt(militaryMatch[1], 10);
}
return null;
}
/**
* Determine time of day based on hour
*/
function getTimeOfDay(hour) {
if (hour === null) return 'unknown';
// Night: 8 PM (20:00) to 5 AM (05:00)
if (hour >= 20 || hour < 5) return 'night';
// Dawn/Dusk: 5 AM - 7 AM and 6 PM - 8 PM
if (hour >= 5 && hour < 7) return 'dawn';
if (hour >= 18 && hour < 20) return 'dusk';
// Day: 7 AM to 6 PM
return 'day';
}
/**
* Extract time from Info Box data
*/
function getCurrentTime() {
const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox || '';
// Try to parse as JSON first (new format)
try {
const parsed = typeof infoBoxData === 'string' ? repairJSON(infoBoxData) : infoBoxData;
if (parsed && parsed.time) {
// Use the end time if available (current time), otherwise start time
return parsed.time.end || parsed.time.start || null;
}
} catch (e) {
// Not JSON, try old text format
}
// Fallback: Parse the old text format to find Time field
const lines = infoBoxData.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('Time:')) {
const timeStr = trimmed.substring('Time:'.length).trim();
// If it contains →, take the end time (after arrow)
if (timeStr.includes('→')) {
const parts = timeStr.split('→');
return parts[1]?.trim() || parts[0]?.trim();
}
return timeStr;
}
}
return null;
}
/**
* Parse weather text to determine effect type
@@ -136,22 +233,171 @@ function createMist() {
}
/**
* Create sunshine rays effect
* Calculate sun position based on hour (arc across sky)
* Returns { left: vw%, top: dvh% }
*/
function createSunshine() {
const container = document.createElement('div');
container.className = 'rpg-weather-particles';
function calculateSunPosition(hour) {
// Daytime is roughly 6 AM to 8 PM (6-20)
// Map hour to position along an arc
// 6 AM = far left, low | 12 PM = center, high | 6 PM = far right, low
if (hour === null) hour = 12; // Default to noon if unknown
// Clamp to daytime hours
const clampedHour = Math.max(5, Math.min(20, hour));
// Normalize to 0-1 range (5 AM = 0, 20 PM = 1)
const progress = (clampedHour - 5) / 15;
// Horizontal position: 5% to 85% (left to right)
const left = 5 + progress * 80;
// Vertical position: parabolic arc (high at noon, low at dawn/dusk)
// At progress 0.5 (noon), top should be ~8% (high)
// At progress 0 or 1, top should be ~35% (low, near horizon)
const normalizedProgress = (progress - 0.5) * 2; // -1 to 1
const top = 8 + 27 * (normalizedProgress * normalizedProgress);
return { left, top };
}
// Create 8 sun rays
for (let i = 0; i < 8; i++) {
const ray = document.createElement('div');
ray.className = 'rpg-weather-particle rpg-sunray';
ray.style.left = `${10 + i * 12}%`;
ray.style.animationDelay = `${i * 0.5}s`;
ray.style.animationDuration = `${8 + Math.random() * 4}s`;
container.appendChild(ray);
/**
* Create clear/sunny weather effect with floating particles and warm glow
*/
function createSunshine(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-clear-weather';
// Create the sun based on current hour
const sunPos = calculateSunPosition(hour);
const sun = document.createElement('div');
sun.className = 'rpg-weather-particle rpg-clear-sun';
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
container.appendChild(sun);
// Create sun glow
const sunGlow = document.createElement('div');
sunGlow.className = 'rpg-weather-particle rpg-clear-sun-glow';
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
container.appendChild(sunGlow);
// Create warm ambient glow overlay
const ambientGlow = document.createElement('div');
ambientGlow.className = 'rpg-weather-particle rpg-clear-ambient-glow';
container.appendChild(ambientGlow);
// Create floating dust motes / pollen particles (golden sparkles)
for (let i = 0; i < 25; i++) {
const particle = document.createElement('div');
particle.className = 'rpg-weather-particle rpg-clear-dust-mote';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.top = `${Math.random() * 100}dvh`;
particle.style.animationDelay = `${Math.random() * 15}s`;
particle.style.animationDuration = `${12 + Math.random() * 8}s`;
// Vary the size slightly
const size = 2 + Math.random() * 4;
particle.style.width = `${size}px`;
particle.style.height = `${size}px`;
container.appendChild(particle);
}
// Create soft light orbs that drift gently
for (let i = 0; i < 6; i++) {
const orb = document.createElement('div');
orb.className = 'rpg-weather-particle rpg-clear-light-orb';
orb.style.left = `${10 + Math.random() * 80}vw`;
orb.style.top = `${10 + Math.random() * 80}dvh`;
orb.style.animationDelay = `${i * 2}s`;
orb.style.animationDuration = `${20 + Math.random() * 10}s`;
// Vary the size
const size = 80 + Math.random() * 120;
orb.style.width = `${size}px`;
orb.style.height = `${size}px`;
container.appendChild(orb);
}
// Create lens flare effect in corner
const lensFlare = document.createElement('div');
lensFlare.className = 'rpg-weather-particle rpg-clear-lens-flare';
container.appendChild(lensFlare);
return container;
}
/**
* Create clear nighttime weather effect with moon, stars, and fireflies
*/
function createNighttime(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-night-weather';
// Create dark blue ambient overlay
const nightOverlay = document.createElement('div');
nightOverlay.className = 'rpg-weather-particle rpg-night-overlay';
container.appendChild(nightOverlay);
// Calculate moon position based on hour
const moonPos = calculateMoonPosition(hour);
// Create the moon
const moon = document.createElement('div');
moon.className = 'rpg-weather-particle rpg-night-moon';
moon.style.left = `${moonPos.left}vw`;
moon.style.top = `${moonPos.top}dvh`;
container.appendChild(moon);
// Create moon glow
const moonGlow = document.createElement('div');
moonGlow.className = 'rpg-weather-particle rpg-night-moon-glow';
moonGlow.style.left = `${moonPos.left - 3}vw`;
moonGlow.style.top = `${moonPos.top - 3}dvh`;
container.appendChild(moonGlow);
// Create twinkling stars
for (let i = 0; i < 60; i++) {
const star = document.createElement('div');
star.className = 'rpg-weather-particle rpg-night-star';
star.style.left = `${Math.random() * 100}vw`;
star.style.top = `${Math.random() * 60}dvh`; // Stars mostly in upper portion
star.style.animationDelay = `${Math.random() * 5}s`;
star.style.animationDuration = `${2 + Math.random() * 3}s`;
// Vary the size
const size = 1 + Math.random() * 2;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
container.appendChild(star);
}
// Create a few brighter stars
for (let i = 0; i < 8; i++) {
const brightStar = document.createElement('div');
brightStar.className = 'rpg-weather-particle rpg-night-star rpg-night-star-bright';
brightStar.style.left = `${Math.random() * 100}vw`;
brightStar.style.top = `${Math.random() * 50}dvh`;
brightStar.style.animationDelay = `${Math.random() * 4}s`;
brightStar.style.animationDuration = `${3 + Math.random() * 2}s`;
container.appendChild(brightStar);
}
// Create fireflies / floating light particles
for (let i = 0; i < 15; i++) {
const firefly = document.createElement('div');
firefly.className = 'rpg-weather-particle rpg-night-firefly';
firefly.style.left = `${Math.random() * 100}vw`;
firefly.style.top = `${40 + Math.random() * 55}dvh`; // Fireflies in lower portion
firefly.style.animationDelay = `${Math.random() * 10}s`;
firefly.style.animationDuration = `${8 + Math.random() * 7}s`;
container.appendChild(firefly);
}
// Create subtle shooting star occasionally
const shootingStar = document.createElement('div');
shootingStar.className = 'rpg-weather-particle rpg-night-shooting-star';
container.appendChild(shootingStar);
return container;
}
@@ -190,6 +436,75 @@ function createWind() {
return container;
}
/**
* Calculate moon position based on hour (arc across sky at night)
* Returns { left: vw%, top: dvh% }
*/
function calculateMoonPosition(hour) {
// Nighttime is roughly 8 PM to 5 AM (20-5)
// Map hour to position along an arc
// 8 PM = far left, low | midnight = center-left, high | 5 AM = far right, low
if (hour === null) hour = 0; // Default to midnight if unknown
// Normalize night hours to 0-1 range
// 20 (8 PM) = 0, 0 (midnight) = ~0.44, 5 (5 AM) = 1
let progress;
if (hour >= 20) {
// 8 PM to midnight: 20-24 maps to 0-0.44
progress = (hour - 20) / 9;
} else {
// Midnight to 5 AM: 0-5 maps to 0.44-1
progress = (hour + 4) / 9;
}
// Horizontal position: 10% to 80% (left to right)
const left = 10 + progress * 70;
// Vertical position: parabolic arc (high at ~2 AM, low at dusk/dawn)
// Peak should be around progress 0.67 (~2 AM)
const peakProgress = 0.5;
const normalizedProgress = (progress - peakProgress) * 2; // -1 to 1
const top = 8 + 25 * (normalizedProgress * normalizedProgress);
return { left, top };
}
/**
* Update sun/moon position without recreating the whole effect
*/
function updateCelestialPosition(hour) {
if (!weatherContainer) return false;
// Update sun position if it exists
const sun = weatherContainer.querySelector('.rpg-clear-sun');
const sunGlow = weatherContainer.querySelector('.rpg-clear-sun-glow');
if (sun && sunGlow) {
const sunPos = calculateSunPosition(hour);
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
return true;
}
// Update moon position if it exists
const moon = weatherContainer.querySelector('.rpg-night-moon');
const moonGlow = weatherContainer.querySelector('.rpg-night-moon-glow');
if (moon && moonGlow) {
const moonPos = calculateMoonPosition(hour);
moon.style.left = `${moonPos.left}vw`;
moon.style.top = `${moonPos.top}dvh`;
moonGlow.style.left = `${moonPos.left - 3}vw`;
moonGlow.style.top = `${moonPos.top - 3}dvh`;
return true;
}
return false;
}
/**
* Remove current weather effect
*/
@@ -198,11 +513,13 @@ function removeWeatherEffect() {
weatherContainer.remove();
weatherContainer = null;
currentWeatherType = null;
currentTimeOfDay = null;
currentHour = null;
}
}
/**
* Update weather effect based on current weather
* Update weather effect based on current weather and time
*/
export function updateWeatherEffect() {
// Check if dynamic weather is enabled
@@ -214,8 +531,21 @@ export function updateWeatherEffect() {
const weather = getCurrentWeather();
const weatherType = parseWeatherType(weather);
// Don't recreate if weather hasn't changed
if (weatherType === currentWeatherType) {
// Get current time of day
const timeStr = getCurrentTime();
const hour = parseHourFromTime(timeStr);
const timeOfDay = getTimeOfDay(hour);
// If only the hour changed (same weather and time of day), just update celestial position
if (weatherType === currentWeatherType && timeOfDay === currentTimeOfDay && hour !== currentHour) {
if (updateCelestialPosition(hour)) {
currentHour = hour;
return; // Successfully updated position without recreating
}
}
// Don't recreate if nothing has changed
if (weatherType === currentWeatherType && timeOfDay === currentTimeOfDay && hour === currentHour) {
return;
}
@@ -228,6 +558,8 @@ export function updateWeatherEffect() {
}
currentWeatherType = weatherType;
currentTimeOfDay = timeOfDay;
currentHour = hour;
switch (weatherType) {
case 'snow':
@@ -240,7 +572,12 @@ export function updateWeatherEffect() {
weatherContainer = createMist();
break;
case 'sunny':
weatherContainer = createSunshine();
// Use nighttime effect for clear weather at night
if (timeOfDay === 'night') {
weatherContainer = createNighttime(hour);
} else {
weatherContainer = createSunshine(hour);
}
break;
case 'wind':
weatherContainer = createWind();
@@ -270,6 +607,18 @@ export function updateWeatherEffect() {
}
if (weatherContainer) {
// Apply z-index based on background/foreground settings
if (extensionSettings.weatherForeground) {
weatherContainer.style.zIndex = '9998'; // In front of chat
weatherContainer.classList.add('rpg-weather-foreground');
} else if (extensionSettings.weatherBackground) {
weatherContainer.style.zIndex = '1'; // Behind chat (default)
weatherContainer.classList.remove('rpg-weather-foreground');
} else {
// Both disabled - don't show weather
return;
}
document.body.appendChild(weatherContainer);
}
}
+11 -6
View File
@@ -11,13 +11,17 @@
* @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);
if (typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input type:', typeof jsonString);
return null;
}
let cleaned = jsonString.trim();
if (!cleaned) {
return null;
}
// Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, '');
@@ -147,7 +151,8 @@ export function extractJSONFromText(text) {
// Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
if (fenceMatch && fenceMatch[1]) {
return fenceMatch[1].trim();
const trimmed = fenceMatch[1].trim();
if (trimmed) return trimmed;
}
// Try to extract from ``` code fence (without json label)
@@ -155,20 +160,20 @@ export function extractJSONFromText(text) {
if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
if (content && (content.startsWith('{') || content.startsWith('['))) {
return content;
}
}
// Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch) {
if (objectMatch && objectMatch[0].trim()) {
return objectMatch[0];
}
// Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
if (arrayMatch && arrayMatch[0].trim()) {
return arrayMatch[0];
}
+1743 -35
View File
File diff suppressed because it is too large Load Diff
+260 -14
View File
@@ -4,6 +4,46 @@
<i class="fa-solid fa-chevron-right"></i>
</button>
<!-- Strip Widget Container (shown when collapsed with strip widgets enabled) -->
<div id="rpg-strip-widget-container" class="rpg-strip-widget-container">
<!-- Weather Icon Widget -->
<div class="rpg-strip-widget rpg-strip-widget-weather" data-widget="weatherIcon">
<span class="rpg-strip-widget-icon"></span>
<span class="rpg-strip-widget-desc"></span>
</div>
<!-- Clock Widget with animated face -->
<div class="rpg-strip-widget rpg-strip-widget-clock" data-widget="clock">
<div class="rpg-strip-clock-face">
<div class="rpg-strip-clock-hour"></div>
<div class="rpg-strip-clock-minute"></div>
<div class="rpg-strip-clock-center"></div>
</div>
<span class="rpg-strip-widget-value"></span>
</div>
<!-- Date Widget -->
<div class="rpg-strip-widget rpg-strip-widget-date" data-widget="date">
<i class="fa-solid fa-calendar"></i>
<span class="rpg-strip-widget-value"></span>
</div>
<!-- Location Widget -->
<div class="rpg-strip-widget rpg-strip-widget-location" data-widget="location">
<i class="fa-solid fa-location-dot"></i>
<span class="rpg-strip-widget-value"></span>
</div>
<!-- Stats Widget -->
<div class="rpg-strip-widget rpg-strip-widget-stats" data-widget="stats">
<div class="rpg-strip-stats-list"></div>
</div>
<!-- Attributes Widget -->
<div class="rpg-strip-widget rpg-strip-widget-attributes" data-widget="attributes">
<div class="rpg-strip-attributes-grid"></div>
</div>
<!-- Refresh Button (bottom) -->
<button id="rpg-strip-refresh" class="rpg-strip-refresh-btn" title="Refresh RPG Info">
<i class="fa-solid fa-sync"></i>
</button>
</div>
<!-- Main Game Panel -->
<div class="rpg-game-container">
<!-- Header with Controls -->
@@ -90,6 +130,24 @@
</label>
</div>
<!-- Deception System Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-deception-toggle-wrapper">
<label class="rpg-toggle-label" title="Deception System">
<input type="checkbox" id="rpg-toggle-deception">
<i class="fa-solid fa-masks-theater"></i>
<span class="rpg-toggle-text" data-i18n-key="template.mainPanel.deceptionSystem">Deception System</span>
</label>
</div>
<!-- CYOA Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-cyoa-toggle-wrapper">
<label class="rpg-toggle-label" title="CYOA">
<input type="checkbox" id="rpg-toggle-cyoa">
<i class="fa-solid fa-list-ol"></i>
<span class="rpg-toggle-text" data-i18n-key="template.mainPanel.cyoa">CYOA</span>
</label>
</div>
<!-- Spotify Music Toggle -->
<div class="rpg-toggle-container rpg-feature-col" id="rpg-spotify-toggle-wrapper">
<label class="rpg-toggle-label" title="Spotify Music">
@@ -321,6 +379,24 @@
Display a toggle button to enable/disable colored dialogue formatting.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-deception-toggle" />
<span data-i18n-key="template.settingsModal.display.showDeceptionToggle">Show Deception System</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showDeceptionToggleNote">
Display a toggle button to enable/disable special formatting of lies and deceptions crafted by the model, allowing it to easily track whenever one was committed, without showing it to the user.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-cyoa-toggle" />
<span data-i18n-key="template.settingsModal.display.showCYOAToggle">Show CYOA</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showCYOAToggleNote">
Display a toggle button to enable/disable "Choose Your Own Adventure" formatting instruction that makes the model produce five possible actions/dialogues for you to choose from at the end of the output.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-spotify-toggle" />
<span data-i18n-key="template.settingsModal.display.showSpotifyMusicToggle">Show Spotify Music</span>
@@ -339,6 +415,25 @@
Display a toggle button to enable/disable animated weather effects.
</small>
<!-- Weather sub-options (shown when dynamic weather is enabled) -->
<div id="rpg-weather-suboptions" style="margin-left: 24px; margin-top: 8px;">
<label class="checkbox_label">
<input type="radio" name="rpg-weather-position" id="rpg-toggle-weather-background" />
<span>Show in Background</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;">
Display weather effects behind the chat (standard behavior).
</small>
<label class="checkbox_label">
<input type="radio" name="rpg-weather-position" id="rpg-toggle-weather-foreground" />
<span>Show in Foreground</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;">
Display weather effects in front of the chat (experimental).
</small>
</div>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-show-narrator-mode" />
<span data-i18n-key="template.settingsModal.display.showNarratorMode">Show Narrator Mode</span>
@@ -395,6 +490,111 @@
</div>
<!-- Mobile FAB Options Section -->
<div class="rpg-settings-group">
<h4 data-i18n-key="template.settingsModal.mobileFabTitle">Mobile Button Widgets</h4>
<small class="notes" style="display: block; margin-bottom: 10px;"
data-i18n-key="template.settingsModal.mobileFabNote">
Show compact info widgets around the floating button on mobile. Widgets are positioned automatically.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-widgets-enabled" />
<span data-i18n-key="template.settingsModal.mobileFab.enabled">Enable Floating Mobile Widgets</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.mobileFab.enabledNote">
Master toggle to show info widgets around the mobile floating button.
</small>
<div id="rpg-fab-widget-options" style="margin-left: 10px; border-left: 2px solid var(--SmartThemeBorderColor); padding-left: 10px; margin-top: 8px;">
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-weather-icon" />
<span data-i18n-key="template.settingsModal.mobileFab.weatherIcon">Weather Icon</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-weather-desc" />
<span data-i18n-key="template.settingsModal.mobileFab.weatherDesc">Weather Description</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-clock" />
<span data-i18n-key="template.settingsModal.mobileFab.clock">Time/Clock</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-date" />
<span data-i18n-key="template.settingsModal.mobileFab.date">Date</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-location" />
<span data-i18n-key="template.settingsModal.mobileFab.location">Location</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-stats" />
<span data-i18n-key="template.settingsModal.mobileFab.stats">Stats (Health, Energy, etc.)</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-fab-attributes" />
<span data-i18n-key="template.settingsModal.mobileFab.attributes">RPG Attributes (STR, DEX, etc.)</span>
</label>
</div>
</div>
<!-- Desktop Strip Widgets Section -->
<div class="rpg-settings-group">
<h4 data-i18n-key="template.settingsModal.desktopStripTitle">Desktop Collapsed Strip Widgets</h4>
<small class="notes" style="display: block; margin-bottom: 10px;"
data-i18n-key="template.settingsModal.desktopStripNote">
Show compact info widgets in the collapsed panel strip on desktop. Displays stats vertically without needing to expand the panel.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-strip-widgets-enabled" />
<span data-i18n-key="template.settingsModal.desktopStrip.enabled">Enable Strip Widgets</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.desktopStrip.enabledNote">
Shows widgets in the collapsed panel strip for quick access to stats.
</small>
<div id="rpg-strip-widget-options" style="margin-left: 10px; border-left: 2px solid var(--SmartThemeBorderColor); padding-left: 10px; margin-top: 8px; display: none;">
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-strip-weather-icon" />
<span data-i18n-key="template.settingsModal.desktopStrip.weatherIcon">Weather Icon</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-strip-clock" />
<span data-i18n-key="template.settingsModal.desktopStrip.clock">Time/Clock</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-strip-date" />
<span data-i18n-key="template.settingsModal.desktopStrip.date">Date</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-strip-location" />
<span data-i18n-key="template.settingsModal.desktopStrip.location">Location</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-strip-stats" />
<span data-i18n-key="template.settingsModal.desktopStrip.stats">Stats (Health, Energy, etc.)</span>
</label>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-strip-attributes" />
<span data-i18n-key="template.settingsModal.desktopStrip.attributes">RPG Attributes (STR, DEX, etc.)</span>
</label>
</div>
</div>
<div class="rpg-settings-group">
<h4 data-i18n-key="template.settingsModal.advancedTitle"><i class="fa-solid fa-sliders"
aria-hidden="true"></i> Advanced</h4>
@@ -506,19 +706,7 @@
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">
<div class="rpg-setting-row" style="margin-top: 16px;">
<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" />
@@ -588,7 +776,7 @@
</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.
Clears committed and displayed tracker data for your currently active chat.
</small>
</div>
</div>
@@ -670,6 +858,31 @@
aria-label="Close tracker editor">&times;</button>
</header>
<!-- Preset Management Section -->
<div class="rpg-preset-management">
<div class="rpg-preset-row">
<label for="rpg-preset-select">Preset:</label>
<select id="rpg-preset-select" class="rpg-select">
<!-- Options populated by JavaScript -->
</select>
<button id="rpg-preset-new" class="rpg-btn-icon" type="button" title="Create New Preset">
<i class="fa-solid fa-plus"></i>
</button>
<button id="rpg-preset-default" class="rpg-btn-icon" type="button" title="Set as Default Preset">
<i class="fa-solid fa-star"></i>
</button>
<button id="rpg-preset-delete" class="rpg-btn-icon" type="button" title="Delete Current Preset">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<div class="rpg-preset-association-row">
<label class="checkbox_label">
<input type="checkbox" id="rpg-preset-associate">
<span>Use this preset for: <strong id="rpg-preset-entity-name">Character</strong></span>
</label>
</div>
</div>
<!-- Tabs -->
<div class="rpg-editor-tabs">
<button class="rpg-editor-tab active" data-tab="userStats">
@@ -684,6 +897,10 @@
<i class="fa-solid fa-users"></i> <span
data-i18n-key="template.trackerEditorModal.tabs.presentCharacters">Present Characters</span>
</button>
<button class="rpg-editor-tab" data-tab="historyPersistence">
<i class="fa-solid fa-clock-rotate-left"></i> <span
data-i18n-key="template.trackerEditorModal.tabs.historyPersistence">History Persistence</span>
</button>
</div>
<div class="rpg-settings-popup-body">
@@ -691,6 +908,7 @@
<div id="rpg-editor-tab-userStats" class="rpg-editor-tab-content"></div>
<div id="rpg-editor-tab-infoBox" class="rpg-editor-tab-content" style="display: none;"></div>
<div id="rpg-editor-tab-presentCharacters" class="rpg-editor-tab-content" style="display: none;"></div>
<div id="rpg-editor-tab-historyPersistence" class="rpg-editor-tab-content" style="display: none;"></div>
</div>
<footer class="rpg-settings-popup-footer">
@@ -764,6 +982,34 @@
</button>
</div>
<!-- Deception System Prompt -->
<div class="rpg-prompt-editor-section">
<label for="rpg-prompt-deception" style="display: block; margin-bottom: 8px; font-weight: 600;">
<i class="fa-solid fa-masks-theater"></i> Deception System Prompt
</label>
<small style="display: block; margin-bottom: 8px; color: #888; font-size: 11px;">
Injected when "Enable Deception System" is enabled. Instructs AI to mark lies and deceptions with hidden tags.
</small>
<textarea id="rpg-prompt-deception" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="deception" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button>
</div>
<!-- CYOA Prompt -->
<div class="rpg-prompt-editor-section">
<label for="rpg-prompt-cyoa" style="display: block; margin-bottom: 8px; font-weight: 600;">
<i class="fa-solid fa-list-ol"></i> CYOA Prompt
</label>
<small style="display: block; margin-bottom: 8px; color: #888; font-size: 11px;">
Injected when "Enable CYOA" is enabled. Instructs AI to end responses with numbered action choices. Uses very high priority (depth 102) to ensure it's the last instruction.
</small>
<textarea id="rpg-prompt-cyoa" class="rpg-prompt-textarea" rows="4"></textarea>
<button class="menu_button rpg-restore-prompt-btn" data-prompt="cyoa" style="margin-top: 8px;">
<i class="fa-solid fa-rotate-left"></i>&nbsp;Restore Default
</button>
</div>
<!-- Spotify Music Prompt -->
<div class="rpg-prompt-editor-section">
<label for="rpg-prompt-spotify" style="display: block; margin-bottom: 8px; font-weight: 600;">